diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a0c978a7..518bb2a4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.13.7 +current_version = 0.19.5 tag = True tag_name = {new_version} commit = True @@ -9,3 +9,7 @@ serialize = {major}.{minor}.{patch} [bumpversion:file:openapi_core/__init__.py] + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e3569e69 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/00_bug_report.yml b/.github/ISSUE_TEMPLATE/00_bug_report.yml new file mode 100644 index 00000000..ee64fdee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/00_bug_report.yml @@ -0,0 +1,88 @@ +name: "Report a Bug" +description: "Report a bug about unexpected error, a crash, or otherwise incorrect behavior while using the library." +title: "[Bug]: " +labels: ["kind/bug"] +body: + - type: markdown + attributes: + value: | + Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What happened? + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Please list the steps required to reproduce the issue. As minimally and precisely as possible. + validations: + required: true + + - type: input + id: openapi_core_version + attributes: + label: OpenAPI Core Version + description: The semantic version of OpenAPI Core used when experiencing the bug. If multiple versions have been tested, a comma separated list. + placeholder: "X.Y.Z" + validations: + required: true + + - type: input + id: openapi_core_integration + attributes: + label: OpenAPI Core Integration + description: What integration did you use. + placeholder: "django, flask, etc." + validations: + required: true + + - type: textarea + id: affected + attributes: + label: Affected Area(s) + description: Please list the affected area(s). + placeholder: "casting, dependencies, deserializing, documentation, schema, security, unmarshalling, validation" + validations: + required: false + + - type: textarea + id: references + attributes: + label: References + description: | + Where possible, please supply links to documentations, other GitHub issues (open or closed) or pull requests that give additional context. + validations: + required: false + + - type: textarea + id: other + attributes: + label: Anything else we need to know? + validations: + required: false + + - type: dropdown + id: will_contribute + attributes: + label: Would you like to implement a fix? + description: | + If you plan to implement a fix for this. + options: + - "No" + - "Yes" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/01_enhancement.yml b/.github/ISSUE_TEMPLATE/01_enhancement.yml new file mode 100644 index 00000000..895f1a20 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_enhancement.yml @@ -0,0 +1,40 @@ +name: "Request new Feature" +description: "Provide supporting details for an enhancement for the library." +title: "[Feature]: " +labels: ["kind/enhancement"] +body: + - type: textarea + id: feature + attributes: + label: Suggested Behavior + description: What would you like to be added? + validations: + required: true + + - type: textarea + id: rationale + attributes: + label: Why is this needed? + validations: + required: true + + - type: textarea + id: references + attributes: + label: References + description: | + Where possible, please supply links to documentations that give additional context. + validations: + required: false + + - type: dropdown + id: will_contribute + attributes: + label: Would you like to implement a feature? + description: | + If you plan to implement a feature for this. + options: + - "No" + - "Yes" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3c4d4576 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: "Python OpenAPI Contributing: Reporting Bugs" + url: https://openapi-core.readthedocs.io/en/latest/contributing.html#reporting-bugs + about: Read guidance about Reporting Bugs in the repository. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..645c171a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml new file mode 100644 index 00000000..4d93c341 --- /dev/null +++ b/.github/workflows/build-docs.yml @@ -0,0 +1,54 @@ +name: Build documentation + +on: + push: + pull_request: + types: [opened, synchronize] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: Get full Python version + id: full-python-version + run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") + + - name: Set up poetry + uses: Gr1N/setup-poetry@v9 + with: + poetry-version: "2.1.1" + + - name: Configure poetry + run: poetry config virtualenvs.in-project true + + - name: Set up cache + uses: actions/cache@v4 + id: cache + with: + path: .venv + key: venv-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Ensure cache is healthy + if: steps.cache.outputs.cache-hit == 'true' + run: timeout 10s poetry run pip --version || rm -rf .venv + + - name: Install dependencies + run: poetry install --with docs + + - name: Build documentation + run: | + poetry run python -m mkdocs build --clean --site-dir ./_build/html --config-file mkdocs.yml + + - uses: actions/upload-artifact@v4 + name: Upload docs as artifact + with: + name: docs-html + path: './_build/html' + if-no-files-found: error diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index b6082213..41ccb29e 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -7,33 +7,30 @@ on: workflow_dispatch: release: types: - - created + - published jobs: publish: runs-on: ubuntu-latest - strategy: - matrix: - python-version: [2.7, 3.6] + permissions: + id-token: write steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build - run: python setup.py sdist bdist_wheel - - name: Publish wheel - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: twine upload dist/*.whl - - name: Publish source - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: twine upload dist/*.tar.gz || true + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Set up poetry + uses: Gr1N/setup-poetry@v9 + with: + poetry-version: "2.1.1" + + - name: Build + run: poetry build + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 9d22fb30..93b4a806 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -10,24 +10,95 @@ on: jobs: test: + name: "Tests" runs-on: ubuntu-latest strategy: matrix: - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] fail-fast: false steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements_dev.txt - pip install -e . - - name: Test - run: python setup.py test - - name: Upload coverage - uses: codecov/codecov-action@v1 + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Get full Python version + id: full-python-version + run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") + + - name: Set up poetry + uses: Gr1N/setup-poetry@v9 + with: + poetry-version: "2.1.1" + + - name: Configure poetry + run: poetry config virtualenvs.in-project true + + - name: Set up cache + uses: actions/cache@v4 + id: cache + with: + path: .venv + key: venv-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Ensure cache is healthy + if: steps.cache.outputs.cache-hit == 'true' + run: timeout 10s poetry run pip --version || rm -rf .venv + + - name: Install dependencies + run: poetry install --all-extras + + - name: Test + env: + PYTEST_ADDOPTS: "--color=yes" + run: poetry run pytest + + - name: Static type check + run: poetry run mypy + + - name: Check dependencies + run: poetry run deptry . + + - name: Upload coverage + uses: codecov/codecov-action@v5 + + static-checks: + name: "Static checks" + runs-on: ubuntu-latest + steps: + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" + uses: actions/checkout@v4 + + - name: "Setup Python" + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: Get full Python version + id: full-python-version + run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") + + - name: Set up poetry + uses: Gr1N/setup-poetry@v9 + + - name: Configure poetry + run: poetry config virtualenvs.in-project true + + - name: Set up cache + uses: actions/cache@v4 + id: cache + with: + path: .venv + key: venv-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Ensure cache is healthy + if: steps.cache.outputs.cache-hit == 'true' + run: timeout 10s poetry run pip --version || rm -rf .venv + + - name: Install dependencies + run: poetry install + + - name: Run static checks + run: poetry run pre-commit run -a diff --git a/.gitignore b/.gitignore index 89a475dc..8ae61294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Byte-compiled / optimized / DLL files -__pycache__/ +**/__pycache__/ *.py[cod] *$py.class .pytest_cache/ @@ -63,7 +63,7 @@ instance/ .scrapy # Sphinx documentation -docs/_build/ +docs_build/ # PyBuilder target/ @@ -98,8 +98,14 @@ ENV/ # mkdocs documentation /site +# asdf versions +.tool-versions +.default-python-packages + # mypy .mypy_cache/ # Jetbrains project files .idea/ + +/reports/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1a006f53 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +--- +default_stages: [commit, push] +default_language_version: + # force all unspecified python hooks to run python3 + python: python3 +minimum_pre_commit_version: "1.20.0" +repos: + - repo: meta + hooks: + - id: check-hooks-apply + + - repo: https://github.com/asottile/pyupgrade + rev: v2.38.4 + hooks: + - id: pyupgrade + args: ["--py36-plus"] + + - repo: local + hooks: + - id: flynt + name: Convert to f-strings with flynt + entry: flynt + language: python + additional_dependencies: ['flynt==0.64'] + + - id: black + name: black + entry: black + language: system + require_serial: true + types: [python] + + - id: isort + name: isort + entry: isort + args: ['--filter-files'] + language: system + require_serial: true + types: [python] + + - id: pyflakes + name: pyflakes + entry: pyflakes + language: system + require_serial: true + types: [python] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..bde1686a --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +# Build documentation with Mkdocs +mkdocs: + configuration: mkdocs.yml + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + jobs: + post_system_dependencies: + - asdf plugin-add poetry + - asdf install poetry 2.1.1 + - asdf global poetry 2.1.1 + post_install: + - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --no-interaction --with docs diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5f2f80a8..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python -sudo: false -matrix: - include: - - python: 2.7 - - python: 3.5 - - python: 3.6 - - python: 3.7 - - python: 3.8 - - python: 3.9 - - python: nightly - - python: pypy3 - allow_failures: - - python: nightly -before_install: -- pip install codecov -- pip install 'py>=1.5.0' -install: -- pip install -r requirements.txt -- pip install -r requirements_dev.txt -- pip install -e . -script: -- python setup.py test -after_success: -- codecov diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..12bdd471 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1 @@ +Please read the `Contributing `__ guidelines in the documentation site. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e05e9132..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include README.rst -include requirements.txt -include requirements_dev.txt -include requirements_2.7.txt diff --git a/Makefile b/Makefile index 05d4fe17..22859444 100644 --- a/Makefile +++ b/Makefile @@ -12,16 +12,16 @@ params: @echo "Version: ${VERSION}" dist-build: - @python setup.py bdist_wheel + @poetry build dist-cleanup: @rm -rf build dist ${PACKAGE_NAME}.egg-info dist-upload: - @twine upload dist/*.whl + @poetry publish test-python: - @python setup.py test + @pytest test-cache-cleanup: @rm -rf .pytest_cache @@ -32,9 +32,18 @@ reports-cleanup: test-cleanup: test-cache-cleanup reports-cleanup docs-html: - sphinx-build -b html docs docs/_build + python -m mkdocs build --clean --site-dir docs_build --config-file mkdocs.yml docs-cleanup: - @rm -rf docs/_build + @rm -rf docs_build cleanup: dist-cleanup test-cleanup + +release/patch: + @bump2version patch + +release/minor: + @bump2version minor + +release/major: + @bump2version major diff --git a/README.md b/README.md new file mode 100644 index 00000000..4021788d --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# openapi-core + + + Package version + + + Continuous Integration + + + Tests coverage + + + Python versions + + + Package format + + + Development status + + +## About + +Openapi-core is a Python library that provides client-side and server-side support +for the [OpenAPI v3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) +and [OpenAPI v3.1](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md) specifications. + + +## Key features + +- **Validation** and **unmarshalling** of request and response data (including webhooks) +- **Integration** with popular libraries (Requests, Werkzeug) and frameworks (Django, Falcon, Flask, Starlette) +- Customization with media type **deserializers** and format **unmarshallers** +- **Security** data providers (API keys, Cookie, Basic, and Bearer HTTP authentications) + + +## Documentation + +Check documentation to see more details about the features. All documentation is in the "docs" directory and online at [openapi-core.readthedocs.io](https://openapi-core.readthedocs.io) + + +## Installation + +Recommended way (via pip): + +``` console +pip install openapi-core +``` + +Alternatively you can download the code and install from the repository: + +``` console +pip install -e git+https://github.com/python-openapi/openapi-core.git#egg=openapi_core +``` + + +## First steps + +First, create your OpenAPI object. + +``` python +from openapi_core import OpenAPI + +openapi = OpenAPI.from_file_path('openapi.json') +``` + +Now you can use it to validate and unmarshal against requests and/or responses. + +``` python +# raises an error if the request is invalid +result = openapi.unmarshal_request(request) +``` + +Retrieve validated and unmarshalled request data. + +``` python +# get parameters +path_params = result.parameters.path +query_params = result.parameters.query +cookies_params = result.parameters.cookies +headers_params = result.parameters.headers +# get body +body = result.body +# get security data +security = result.security +``` + +The request object should implement the OpenAPI Request protocol. Check [Integrations](https://openapi-core.readthedocs.io/en/latest/integrations.html) to find officially supported implementations. + +For more details read about the [Unmarshalling](https://openapi-core.readthedocs.io/en/latest/unmarshalling.html) process. + +If you just want to validate your request/response data without unmarshalling, read about [Validation](https://openapi-core.readthedocs.io/en/latest/validation.html) instead. + + +## Related projects + +- [openapi-spec-validator](https://github.com/python-openapi/openapi-spec-validator) + : A Python library that validates OpenAPI Specs against the OpenAPI 2.0 (aka Swagger), OpenAPI 3.0, and OpenAPI 3.1 specification. The validator aims to check for full compliance with the Specification. +- [openapi-schema-validator](https://github.com/python-openapi/openapi-schema-validator) + : A Python library that validates schema against the OpenAPI Schema Specification v3.0 and OpenAPI Schema Specification v3.1. +- [bottle-openapi-3](https://github.com/cope-systems/bottle-openapi-3) + : OpenAPI 3.0 Support for the Bottle Web Framework +- [pyramid_openapi3](https://github.com/niteoweb/pyramid_openapi3) + : Pyramid addon for OpenAPI3 validation of requests and responses. +- [tornado-openapi3](https://github.com/correl/tornado-openapi3) + : Tornado OpenAPI 3 request and response validation library. + +## License + +The project is under the terms of the BSD 3-Clause License. diff --git a/README.rst b/README.rst deleted file mode 100644 index 23dc617e..00000000 --- a/README.rst +++ /dev/null @@ -1,137 +0,0 @@ -************ -openapi-core -************ - -.. image:: https://img.shields.io/pypi/v/openapi-core.svg - :target: https://pypi.python.org/pypi/openapi-core -.. image:: https://travis-ci.org/p1c2u/openapi-core.svg?branch=master - :target: https://travis-ci.org/p1c2u/openapi-core -.. image:: https://img.shields.io/codecov/c/github/p1c2u/openapi-core/master.svg?style=flat - :target: https://codecov.io/github/p1c2u/openapi-core?branch=master -.. image:: https://img.shields.io/pypi/pyversions/openapi-core.svg - :target: https://pypi.python.org/pypi/openapi-core -.. image:: https://img.shields.io/pypi/format/openapi-core.svg - :target: https://pypi.python.org/pypi/openapi-core -.. image:: https://img.shields.io/pypi/status/openapi-core.svg - :target: https://pypi.python.org/pypi/openapi-core - -About -##### - -Openapi-core is a Python library that adds client-side and server-side support -for the `OpenAPI Specification v3 `__. - -Key features -************ - -* **Validation** of requests and responses -* Schema **casting** and **unmarshalling** -* Media type and parameters **deserialization** -* **Security** providers (API keys, Cookie, Basic and Bearer HTTP authentications) -* Custom **deserializers** and **formats** -* **Integration** with libraries and frameworks - - -Documentation -############# - -Check documentation to see more details about the features. All documentation is in the "docs" directory and online at `openapi-core.readthedocs.io `__ - - -Installation -############ - -Recommended way (via pip): - -:: - - $ pip install openapi-core - -Alternatively you can download the code and install from the repository: - -.. code-block:: bash - - $ pip install -e git+https://github.com/p1c2u/openapi-core.git#egg=openapi_core - - -Usage -##### - -Firstly create your specification: - -.. code-block:: python - - from openapi_core import create_spec - - spec = create_spec(spec_dict) - -Request -******* - -Now you can use it to validate requests - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - - validator = RequestValidator(spec) - result = validator.validate(request) - - # raise errors if request invalid - result.raise_for_errors() - - # get list of errors - errors = result.errors - -and unmarshal request data from validation result - -.. code-block:: python - - # get parameters object with path, query, cookies and headers parameters - validated_params = result.parameters - # or specific parameters - validated_path_params = result.parameters.path - - # get body - validated_body = result.body - - # get security data - validated_security = result.security - -Request object should be instance of OpenAPIRequest class (See `Integrations `__). - -Response -******** - -You can also validate responses - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - - validator = ResponseValidator(spec) - result = validator.validate(request, response) - - # raise errors if response invalid - result.raise_for_errors() - - # get list of errors - errors = result.errors - -and unmarshal response data from validation result - -.. code-block:: python - - # get headers - validated_headers = result.headers - - # get data - validated_data = result.data - -Response object should be instance of OpenAPIResponse class (See `Integrations `__). - -Related projects -################ -* `openapi-spec-validator `__ -* `openapi-schema-validator `__ -* `pyramid_openapi3 `__ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ce5da8f4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +If you believe you have found a security vulnerability in the repository, please report it to us as described below. + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them directly to the repository maintainer. + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + +* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) +* Full paths of source file(s) related to the manifestation of the issue +* The location of the affected source code (tag/branch/commit or direct URL) +* Any special configuration required to reproduce the issue +* Step-by-step instructions to reproduce the issue +* Proof-of-concept or exploit code (if possible) +* Impact of the issue, including how an attacker might exploit the issue +* This information will help us triage your report more quickly. + diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 68450c2e..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,61 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) - -import openapi_core - -# -- Project information ----------------------------------------------------- - -project = 'openapi-core' -copyright = '2021, Artur Maciag' -author = 'Artur Maciag' - -# The full version, including alpha/beta/rc tags -release = openapi_core.__version__ - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.doctest", - "sphinx.ext.intersphinx", - "sphinx.ext.coverage", - "sphinx.ext.viewcode", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..020df77a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,181 @@ +--- +hide: + - navigation +--- + +# Configuration + +OpenAPI accepts a `Config` object that allows users to customize the behavior of validation and unmarshalling processes. + +## Specification Validation + +By default, when creating an OpenAPI instance, the provided specification is also validated. + +If you know that you have a valid specification already, disabling the validator can improve performance. + +``` python hl_lines="1 4 6" +from openapi_core import Config + +config = Config( + spec_validator_cls=None, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +``` + +## Request Validator + +By default, the request validator is selected based on the detected specification version. + +To explicitly validate a: + +- OpenAPI 3.0 spec, import `V30RequestValidator` +- OpenAPI 3.1 spec, import `V31RequestValidator` or `V31WebhookRequestValidator` + +``` python hl_lines="1 4" +from openapi_core import V31RequestValidator + +config = Config( + request_validator_cls=V31RequestValidator, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +openapi.validate_request(request) +``` + +You can also explicitly import `V3RequestValidator`, which is a shortcut to the latest OpenAPI v3 version. + +## Response Validator + +By default, the response validator is selected based on the detected specification version. + +To explicitly validate a: + +- OpenAPI 3.0 spec, import `V30ResponseValidator` +- OpenAPI 3.1 spec, import `V31ResponseValidator` or `V31WebhookResponseValidator` + +``` python hl_lines="1 4" +from openapi_core import V31ResponseValidator + +config = Config( + response_validator_cls=V31ResponseValidator, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +openapi.validate_response(request, response) +``` + +You can also explicitly import `V3ResponseValidator`, which is a shortcut to the latest OpenAPI v3 version. + +## Request Unmarshaller + +By default, the request unmarshaller is selected based on the detected specification version. + +To explicitly validate and unmarshal a request for: + +- OpenAPI 3.0 spec, import `V30RequestUnmarshaller` +- OpenAPI 3.1 spec, import `V31RequestUnmarshaller` or `V31WebhookRequestUnmarshaller` + +``` python hl_lines="1 4" +from openapi_core import V31RequestUnmarshaller + +config = Config( + request_unmarshaller_cls=V31RequestUnmarshaller, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +result = openapi.unmarshal_request(request) +``` + +You can also explicitly import `V3RequestUnmarshaller`, which is a shortcut to the latest OpenAPI v3 version. + +## Response Unmarshaller + +To explicitly validate and unmarshal a response: + +- For OpenAPI 3.0 spec, import `V30ResponseUnmarshaller` +- For OpenAPI 3.1 spec, import `V31ResponseUnmarshaller` or `V31WebhookResponseUnmarshaller` + +``` python hl_lines="1 4" +from openapi_core import V31ResponseUnmarshaller + +config = Config( + response_unmarshaller_cls=V31ResponseUnmarshaller, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +result = openapi.unmarshal_response(request, response) +``` + +You can also explicitly import `V3ResponseUnmarshaller`, which is a shortcut to the latest OpenAPI v3 version. + +## Extra Media Type Deserializers + +The library comes with a set of built-in media type deserializers for formats such as `application/json`, `application/xml`, `application/x-www-form-urlencoded`, and `multipart/form-data`. + +You can also define your own deserializers. To do this, pass a dictionary of custom media type deserializers with the supported MIME types as keys to the `unmarshal_response` function: + +```python hl_lines="11" +def protobuf_deserializer(message): + feature = route_guide_pb2.Feature() + feature.ParseFromString(message) + return feature + +extra_media_type_deserializers = { + 'application/protobuf': protobuf_deserializer, +} + +config = Config( + extra_media_type_deserializers=extra_media_type_deserializers, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) + +result = openapi.unmarshal_response(request, response) +``` + +## Extra Format Validators + +OpenAPI defines a `format` keyword that hints at how a value should be interpreted. For example, a `string` with the format `date` should conform to the RFC 3339 date format. + +OpenAPI comes with a set of built-in format validators, but it's also possible to add custom ones. + +Here's how you can add support for a `usdate` format that handles dates in the form MM/DD/YYYY: + +``` python hl_lines="11" +import re + +def validate_usdate(value): + return bool(re.match(r"^\d{1,2}/\d{1,2}/\d{4}$", value)) + +extra_format_validators = { + 'usdate': validate_usdate, +} + +config = Config( + extra_format_validators=extra_format_validators, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) + +openapi.validate_response(request, response) +``` + +## Extra Format Unmarshallers + +Based on the `format` keyword, openapi-core can also unmarshal values to specific formats. + +The library comes with a set of built-in format unmarshallers, but it's also possible to add custom ones. + +Here's an example with the `usdate` format that converts a value to a date object: + +``` python hl_lines="11" +from datetime import datetime + +def unmarshal_usdate(value): + return datetime.strptime(value, "%m/%d/%Y").date() + +extra_format_unmarshallers = { + 'usdate': unmarshal_usdate, +} + +config = Config( + extra_format_unmarshallers=extra_format_unmarshallers, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) + +result = openapi.unmarshal_response(request, response) +``` diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..9d06634b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,73 @@ +--- +hide: + - navigation +--- + +# Contributing + +Firstly, thank you for taking the time to contribute. + +The following section describes how you can contribute to the openapi-core project on GitHub. + +## Reporting bugs + +### Before you report + +- Check whether your issue already exists in the [Issue tracker](https://github.com/python-openapi/openapi-core/issues). +- Make sure it is not a support request or question better suited for the [Discussion board](https://github.com/python-openapi/openapi-core/discussions). + +### How to submit a report + +- Include a clear title. +- Describe your runtime environment with the exact versions you use. +- Describe the exact steps to reproduce the problem, including minimal code snippets. +- Describe the behavior you observed after following the steps, including console outputs. +- Describe the expected behavior and why, including links to documentation. + +## Code contribution + +### Prerequisites + +Install [Poetry](https://python-poetry.org) by following the [official installation instructions](https://python-poetry.org/docs/#installation). Optionally (but recommended), configure Poetry to create a virtual environment in a folder named `.venv` within the root directory of the project: + +```console +poetry config virtualenvs.in-project true +``` + +### Setup + +To create a development environment and install the runtime and development dependencies, run: + +```console +poetry install +``` + +Then enter the virtual environment created by Poetry: + +```console +poetry shell +``` + +### Static checks + +The project uses static checks with the fantastic [pre-commit](https://pre-commit.com/). Every change is checked on CI, and if it does not pass the tests, it cannot be accepted. If you want to check locally, run the following command to install pre-commit. + +To enable pre-commit checks for commit operations in git, enter: + +```console +pre-commit install +``` + +To run all checks on your staged files, enter: + +```console +pre-commit run +``` + +To run all checks on all files, enter: + +```console +pre-commit run --all-files +``` + +Pre-commit check results are also attached to your PR through integration with GitHub Actions. diff --git a/docs/customizations.rst b/docs/customizations.rst deleted file mode 100644 index dcbf7994..00000000 --- a/docs/customizations.rst +++ /dev/null @@ -1,65 +0,0 @@ -Customizations -============== - -Spec validation ---------------- - -By default, spec dict is validated on spec creation time. Disabling the validation can improve the performance. - -.. code-block:: python - - from openapi_core import create_spec - - spec = create_spec(spec_dict, validate_spec=False) - -Deserializers -------------- - -Pass custom defined media type deserializers dictionary with supported mimetypes as a key to `RequestValidator` or `ResponseValidator` constructor: - -.. code-block:: python - - def protobuf_deserializer(message): - feature = route_guide_pb2.Feature() - feature.ParseFromString(message) - return feature - - custom_media_type_deserializers = { - 'application/protobuf': protobuf_deserializer, - } - - validator = ResponseValidator( - spec, custom_media_type_deserializers=custom_media_type_deserializers) - - result = validator.validate(request, response) - -Formats -------- - -OpenAPI defines a ``format`` keyword that hints at how a value should be interpreted, e.g. a ``string`` with the type ``date`` should conform to the RFC 3339 date format. - -Openapi-core comes with a set of built-in formatters, but it's also possible to add support for custom formatters for `RequestValidator` and `ResponseValidator`. - -Here's how you could add support for a ``usdate`` format that handles dates of the form MM/DD/YYYY: - -.. code-block:: python - - from datetime import datetime - import re - - class USDateFormatter: - def validate(self, value) -> bool: - return bool(re.match(r"^\d{1,2}/\d{1,2}/\d{4}$", value)) - - def unmarshal(self, value): - return datetime.strptime(value, "%m/%d/%y").date - - - custom_formatters = { - 'usdate': USDateFormatter(), - } - - validator = ResponseValidator(spec, custom_formatters=custom_formatters) - - result = validator.validate(request, response) - diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..f6f7886c --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,61 @@ +--- +hide: + - navigation +--- + +# Extensions + +## x-model + +By default, objects are unmarshalled to dictionaries. You can use dynamically created dataclasses by providing the `x-model` property inside the schema definition with the name of the model. + +``` yaml hl_lines="5" title="openapi.yaml" + # ... + components: + schemas: + Coordinates: + x-model: Coordinates + type: object + required: + - lat + - lon + properties: + lat: + type: number + lon: + type: number +``` + +As a result of the unmarshalling process, you will get a `Coordinates` class instance with `lat` and `lon` attributes. + +## x-model-path + +You can use your own dataclasses, pydantic models, or models generated by third-party generators (e.g., [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator)) by providing the `x-model-path` property inside the schema definition with the location of your class. + +``` yaml hl_lines="5" title="openapi.yaml" + # ... + components: + schemas: + Coordinates: + x-model-path: foo.bar.Coordinates + type: object + required: + - lat + - lon + properties: + lat: + type: number + lon: + type: number +``` + +``` python title="foo/bar.py" +from dataclasses import dataclass + +@dataclass +class Coordinates: + lat: float + lon: float +``` + +As a result of the unmarshalling process, you will get an instance of your own dataclass or model. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..9cd92675 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,79 @@ +--- +hide: + - navigation +--- + +# openapi-core + +Openapi-core is a Python library that provides client-side and server-side support +for the [OpenAPI v3.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) +and [OpenAPI v3.1](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md) specifications. + +## Key features + +- [Validation](validation.md) and [Unmarshalling](unmarshalling.md) of request and response data (including webhooks) +- [Integrations](integrations/index.md) with popular libraries (Requests, Werkzeug) and frameworks (Django, Falcon, Flask, Starlette) +- [Configuration](configuration.md) with **media type deserializers** and **format unmarshallers** +- [Security](security.md) data providers (API keys, Cookie, Basic, and Bearer HTTP authentications) + +## Installation + +=== "Pip + PyPI (recommended)" + + ``` console + pip install openapi-core + ``` + +=== "Pip + the source" + + ``` console + pip install -e git+https://github.com/python-openapi/openapi-core.git#egg=openapi_core + ``` + +## First steps + +First, create your OpenAPI object. + +```python +from openapi_core import OpenAPI + +openapi = OpenAPI.from_file_path('openapi.json') +``` + +Now you can use it to validate and unmarshal your requests and/or responses. + +```python +# raises an error if the request is invalid +result = openapi.unmarshal_request(request) +``` + +Retrieve validated and unmarshalled request data: + +```python +# get parameters +path_params = result.parameters.path +query_params = result.parameters.query +cookies_params = result.parameters.cookies +headers_params = result.parameters.headers +# get body +body = result.body +# get security data +security = result.security +``` + +The request object should implement the OpenAPI Request protocol. Check [Integrations](integrations/index.md) to find officially supported implementations. + +For more details, read about the [Unmarshalling](unmarshalling.md) process. + +If you just want to validate your request/response data without unmarshalling, read about [Validation](validation.md) instead. + +## Related projects + +- [openapi-spec-validator](https://github.com/python-openapi/openapi-spec-validator) + : A Python library that validates OpenAPI Specs against the OpenAPI 2.0 (aka Swagger), OpenAPI 3.0, and OpenAPI 3.1 specifications. The validator aims to check for full compliance with the Specification. +- [openapi-schema-validator](https://github.com/python-openapi/openapi-schema-validator) + : A Python library that validates schemas against the OpenAPI Schema Specification v3.0 and OpenAPI Schema Specification v3.1. + +## License + +The project is under the terms of the BSD 3-Clause License. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 33b5f48c..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,43 +0,0 @@ -.. openapi-core documentation master file, created by - sphinx-quickstart on Tue Feb 2 17:41:34 2021. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to openapi-core's documentation! -======================================== - -Openapi-core is a Python library that adds client-side and server-side support -for the `OpenAPI Specification v3 `__. - -Key features ------------- - -* **Validation** of requests and responses -* Schema **casting** and **unmarshalling** -* Media type and parameters **deserialization** -* **Security** providers (API keys, Cookie, Basic and Bearer HTTP authentications) -* Custom **deserializers** and **formats** -* **Integration** with libraries and frameworks - - -Table of contents ------------------ - -.. Navigation/TOC - -.. toctree:: - :maxdepth: 2 - - installation - usage - customizations - integrations - - -Related projects -================ - -* `openapi-spec-validator `__ - Python library that validates OpenAPI Specs against the OpenAPI 2.0 (aka Swagger) and OpenAPI 3.0.0 specification. The validator aims to check for full compliance with the Specification. -* `openapi-schema-validator `__ - Python library that validates schema against the OpenAPI Schema Specification v3.0 which is an extended subset of the JSON Schema Specification Wright Draft 00. diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index ef7032f1..00000000 --- a/docs/installation.rst +++ /dev/null @@ -1,15 +0,0 @@ -Installation -============ - -Recommended way (via pip): - -.. code-block:: console - - $ pip install openapi-core - -Alternatively you can download the code and install from the repository: - -.. code-block:: console - - $ pip install -e git+https://github.com/p1c2u/openapi-core.git#egg=openapi_core - diff --git a/docs/integrations.rst b/docs/integrations.rst deleted file mode 100644 index c7d3e478..00000000 --- a/docs/integrations.rst +++ /dev/null @@ -1,198 +0,0 @@ -Integrations -============ - -Bottle ------- - -See `bottle-openapi-3 `_ project. - - -Django ------- - -This section describes integration with `Django `__ web framework. - -For Django 2.2 you can use DjangoOpenAPIRequest a Django request factory: - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - from openapi_core.contrib.django import DjangoOpenAPIRequest - - openapi_request = DjangoOpenAPIRequest(django_request) - validator = RequestValidator(spec) - result = validator.validate(openapi_request) - -You can use DjangoOpenAPIResponse as a Django response factory: - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - from openapi_core.contrib.django import DjangoOpenAPIResponse - - openapi_response = DjangoOpenAPIResponse(django_response) - validator = ResponseValidator(spec) - result = validator.validate(openapi_request, openapi_response) - - -Falcon ------- - -This section describes integration with `Falcon `__ web framework. - -Middleware -~~~~~~~~~~ - -Falcon API can be integrated by `FalconOpenAPIMiddleware` middleware. - -.. code-block:: python - - from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware - - openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec) - api = falcon.API(middleware=[openapi_middleware]) - -Low level -~~~~~~~~~ - -For Falcon you can use FalconOpenAPIRequest a Falcon request factory: - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - from openapi_core.contrib.falcon import FalconOpenAPIRequestFactory - - openapi_request = FalconOpenAPIRequestFactory.create(falcon_request) - validator = RequestValidator(spec) - result = validator.validate(openapi_request) - -You can use FalconOpenAPIResponse as a Falcon response factory: - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - from openapi_core.contrib.falcon import FalconOpenAPIResponseFactory - - openapi_response = FalconOpenAPIResponseFactory.create(falcon_response) - validator = ResponseValidator(spec) - result = validator.validate(openapi_request, openapi_response) - - -Flask ------ - -This section describes integration with `Flask `__ web framework. - -Decorator -~~~~~~~~~ - -Flask views can be integrated by `FlaskOpenAPIViewDecorator` decorator. - -.. code-block:: python - - from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator - - openapi = FlaskOpenAPIViewDecorator.from_spec(spec) - - @app.route('/home') - @openapi - def home(): - pass - -If you want to decorate class based view you can use the decorators attribute: - -.. code-block:: python - - class MyView(View): - decorators = [openapi] - -View -~~~~ - -As an alternative to the decorator-based integration, Flask method based views can be integrated by inheritance from `FlaskOpenAPIView` class. - -.. code-block:: python - - from openapi_core.contrib.flask.views import FlaskOpenAPIView - - class MyView(FlaskOpenAPIView): - pass - - app.add_url_rule('/home', view_func=MyView.as_view('home', spec)) - -Request parameters -~~~~~~~~~~~~~~~~~~ - -In Flask, all unmarshalled request data are provided as Flask request object's openapi.parameters attribute - -.. code-block:: python - - from flask.globals import request - - @app.route('/browse//') - @openapi - def home(): - browse_id = request.openapi.parameters.path['id'] - page = request.openapi.parameters.query.get('page', 1) - -Low level -~~~~~~~~~ - -You can use FlaskOpenAPIRequest a Flask/Werkzeug request factory: - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - from openapi_core.contrib.flask import FlaskOpenAPIRequest - - openapi_request = FlaskOpenAPIRequest(flask_request) - validator = RequestValidator(spec) - result = validator.validate(openapi_request) - -You can use FlaskOpenAPIResponse as a Flask/Werkzeug response factory: - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - from openapi_core.contrib.flask import FlaskOpenAPIResponse - - openapi_response = FlaskOpenAPIResponse(flask_response) - validator = ResponseValidator(spec) - result = validator.validate(openapi_request, openapi_response) - - -Pyramid -------- - -See `pyramid_openapi3 `_ project. - - -Requests --------- - -This section describes integration with `Requests `__ library. - -Low level -~~~~~~~~~ - -For Requests you can use RequestsOpenAPIRequest a Requests request factory: - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - from openapi_core.contrib.requests import RequestsOpenAPIRequest - - openapi_request = RequestsOpenAPIRequest(requests_request) - validator = RequestValidator(spec) - result = validator.validate(openapi_request) - -You can use RequestsOpenAPIResponse as a Requests response factory: - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - from openapi_core.contrib.requests import RequestsOpenAPIResponse - - openapi_response = RequestsOpenAPIResponse(requests_response) - validator = ResponseValidator(spec) - result = validator.validate(openapi_request, openapi_response) diff --git a/docs/integrations/aiohttp.md b/docs/integrations/aiohttp.md new file mode 100644 index 00000000..196d0e96 --- /dev/null +++ b/docs/integrations/aiohttp.md @@ -0,0 +1,37 @@ +# aiohttp.web + +This section describes integration with [aiohttp.web](https://docs.aiohttp.org/en/stable/web.html) framework. + +## Low level + +The integration defines classes useful for low level integration. + +### Request + +Use `AIOHTTPOpenAPIWebRequest` to create OpenAPI request from aiohttp.web request: + +``` python +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest + +async def hello(request): + request_body = await request.text() + openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body) + openapi.validate_request(openapi_request) + return web.Response(text="Hello, world") +``` + +### Response + +Use `AIOHTTPOpenAPIWebResponse` to create OpenAPI response from aiohttp.web response: + +``` python +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebResponse + +async def hello(request): + request_body = await request.text() + response = web.Response(text="Hello, world") + openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body) + openapi_response = AIOHTTPOpenAPIWebResponse(response) + result = openapi.unmarshal_response(openapi_request, openapi_response) + return response +``` diff --git a/docs/integrations/bottle.md b/docs/integrations/bottle.md new file mode 100644 index 00000000..9bfab6ab --- /dev/null +++ b/docs/integrations/bottle.md @@ -0,0 +1,3 @@ +# Bottle + +For more information, see the [bottle-openapi-3](https://github.com/cope-systems/bottle-openapi-3) project. diff --git a/docs/integrations/django.md b/docs/integrations/django.md new file mode 100644 index 00000000..00c6fef4 --- /dev/null +++ b/docs/integrations/django.md @@ -0,0 +1,131 @@ +# Django + +This section describes the integration with the [Django](https://www.djangoproject.com) web framework. +The integration supports Django version 3.0 and above. + +## Middleware + +Django can be integrated using [middleware](https://docs.djangoproject.com/en/5.0/topics/http/middleware/) to apply OpenAPI validation to your entire application. + +Add `DjangoOpenAPIMiddleware` to your `MIDDLEWARE` list and define `OPENAPI`. + +``` python hl_lines="5 8" title="settings.py" +from openapi_core import OpenAPI + +MIDDLEWARE = [ + # ... + 'openapi_core.contrib.django.middlewares.DjangoOpenAPIMiddleware', +] + +OPENAPI = OpenAPI.from_dict(spec_dict) +``` + +After that, all your requests and responses will be validated. + +You also have access to the unmarshalled result object with all unmarshalled request data through the `openapi` attribute of the request object. + +``` python +from django.views import View + +class MyView(View): + def get(self, request): + # Get parameters object with path, query, cookies, and headers parameters + unmarshalled_params = request.openapi.parameters + # Or specific location parameters + unmarshalled_path_params = request.openapi.parameters.path + + # Get body + unmarshalled_body = request.openapi.body + + # Get security data + unmarshalled_security = request.openapi.security +``` + +### Response validation + +You can skip the response validation process by setting `OPENAPI_RESPONSE_CLS` to `None`. + +``` python hl_lines="9" title="settings.py" +from openapi_core import OpenAPI + +MIDDLEWARE = [ + # ... + 'openapi_core.contrib.django.middlewares.DjangoOpenAPIMiddleware', +] + +OPENAPI = OpenAPI.from_dict(spec_dict) +OPENAPI_RESPONSE_CLS = None +``` + +## Decorator + +Django can be integrated using [view decorators](https://docs.djangoproject.com/en/5.1/topics/http/decorators/) to apply OpenAPI validation to your application's specific views. + +Use `DjangoOpenAPIViewDecorator` with the OpenAPI object to create the decorator. + +``` python hl_lines="1 3 6" +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +openapi_validated = FlaskOpenAPIViewDecorator(openapi) + + +@openapi_validated +def home(): + return "Welcome home" +``` + +You can skip the response validation process by setting `response_cls` to `None`. + +``` python hl_lines="5" +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +openapi_validated = DjangoOpenAPIViewDecorator( + openapi, + response_cls=None, +) +``` + +If you want to decorate a class-based view, you can use the `method_decorator` decorator: + +``` python hl_lines="3" +from django.utils.decorators import method_decorator + +@method_decorator(openapi_validated, name='dispatch') +class MyView(View): + + def get(self, request, *args, **kwargs): + return "Welcome home" +``` + +## Low level + +The integration defines classes useful for low-level integration. + +### Request + +Use `DjangoOpenAPIRequest` to create an OpenAPI request from a Django request: + +``` python +from openapi_core.contrib.django import DjangoOpenAPIRequest + +class MyView(View): + def get(self, request): + openapi_request = DjangoOpenAPIRequest(request) + openapi.validate_request(openapi_request) +``` + +### Response + +Use `DjangoOpenAPIResponse` to create an OpenAPI response from a Django response: + +``` python +from openapi_core.contrib.django import DjangoOpenAPIResponse + +class MyView(View): + def get(self, request): + response = JsonResponse({'hello': 'world'}) + openapi_request = DjangoOpenAPIRequest(request) + openapi_response = DjangoOpenAPIResponse(response) + openapi.validate_response(openapi_request, openapi_response) + return response +``` diff --git a/docs/integrations/falcon.md b/docs/integrations/falcon.md new file mode 100644 index 00000000..ea234592 --- /dev/null +++ b/docs/integrations/falcon.md @@ -0,0 +1,92 @@ +# Falcon + +This section describes the integration with the [Falcon](https://falconframework.org) web framework. +The integration supports Falcon version 3.0 and above. + +!!! warning + + This integration does not support multipart form body requests. + +## Middleware + +The Falcon API can be integrated using the `FalconOpenAPIMiddleware` middleware. + +``` python hl_lines="1 3 7" +from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware + +openapi_middleware = FalconOpenAPIMiddleware.from_spec(spec) + +app = falcon.App( + # ... + middleware=[openapi_middleware], +) +``` + +Additional customization parameters can be passed to the middleware. + +``` python hl_lines="5" +from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware + +openapi_middleware = FalconOpenAPIMiddleware.from_spec( + spec, + extra_format_validators=extra_format_validators, +) + +app = falcon.App( + # ... + middleware=[openapi_middleware], +) +``` + +You can skip the response validation process by setting `response_cls` to `None`. + +``` python hl_lines="5" +from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware + +openapi_middleware = FalconOpenAPIMiddleware.from_spec( + spec, + response_cls=None, +) + +app = falcon.App( + # ... + middleware=[openapi_middleware], +) +``` + +After that, you will have access to the validation result object with all validated request data from the Falcon view through the request context. + +``` python +class ThingsResource: + def on_get(self, req, resp): + # Get the parameters object with path, query, cookies, and headers parameters + validated_params = req.context.openapi.parameters + # Or specific location parameters + validated_path_params = req.context.openapi.parameters.path + + # Get the body + validated_body = req.context.openapi.body + + # Get security data + validated_security = req.context.openapi.security +``` + +## Low level + +You can use `FalconOpenAPIRequest` as a Falcon request factory: + +``` python +from openapi_core.contrib.falcon import FalconOpenAPIRequest + +openapi_request = FalconOpenAPIRequest(falcon_request) +result = openapi.unmarshal_request(openapi_request) +``` + +You can use `FalconOpenAPIResponse` as a Falcon response factory: + +``` python +from openapi_core.contrib.falcon import FalconOpenAPIResponse + +openapi_response = FalconOpenAPIResponse(falcon_response) +result = openapi.unmarshal_response(openapi_request, openapi_response) +``` diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md new file mode 100644 index 00000000..5e07707e --- /dev/null +++ b/docs/integrations/fastapi.md @@ -0,0 +1,56 @@ +# FastAPI + +This section describes integration with [FastAPI](https://fastapi.tiangolo.com) ASGI framework. + +!!! note + + FastAPI also provides OpenAPI support. The main difference is that, unlike FastAPI's code-first approach, OpenAPI-core allows you to leverage your existing specification that aligns with the API-First approach. You can read more about API-first vs. code-first in the [Guide to API-first](https://www.postman.com/api-first/). + +## Middleware + +FastAPI can be integrated by [middleware](https://fastapi.tiangolo.com/tutorial/middleware/) to apply OpenAPI validation to your entire application. + +Add `FastAPIOpenAPIMiddleware` with the OpenAPI object to your `middleware` list. + +``` python hl_lines="2 5" +from fastapi import FastAPI +from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware + +app = FastAPI() +app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi) +``` + +After that, all your requests and responses will be validated. + +You also have access to the unmarshal result object with all unmarshalled request data through the `openapi` scope of the request object. + +``` python +async def homepage(request): + # get parameters object with path, query, cookies and headers parameters + unmarshalled_params = request.scope["openapi"].parameters + # or specific location parameters + unmarshalled_path_params = request.scope["openapi"].parameters.path + + # get body + unmarshalled_body = request.scope["openapi"].body + + # get security data + unmarshalled_security = request.scope["openapi"].security +``` + +### Response validation + +You can skip the response validation process by setting `response_cls` to `None` + +``` python hl_lines="5" +app = FastAPI() +app.add_middleware( + FastAPIOpenAPIMiddleware, + openapi=openapi, + response_cls=None, +) +``` + +## Low level + +For low-level integration, see [Starlette](starlette.md) integration. diff --git a/docs/integrations/flask.md b/docs/integrations/flask.md new file mode 100644 index 00000000..513126e8 --- /dev/null +++ b/docs/integrations/flask.md @@ -0,0 +1,107 @@ +# Flask + +This section describes integration with the [Flask](https://flask.palletsprojects.com) web framework. + +## View decorator + +Flask can be integrated using a [view decorator](https://flask.palletsprojects.com/en/latest/patterns/viewdecorators/) to apply OpenAPI validation to your application's specific views. + +Use `FlaskOpenAPIViewDecorator` with the OpenAPI object to create the decorator. + +``` python hl_lines="1 3 6" +from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator + +openapi_validated = FlaskOpenAPIViewDecorator(openapi) + +@app.route('/home') +@openapi_validated +def home(): + return "Welcome home" +``` + +You can skip the response validation process by setting `response_cls` to `None`. + +``` python hl_lines="5" +from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator + +openapi_validated = FlaskOpenAPIViewDecorator( + openapi, + response_cls=None, +) +``` + +If you want to decorate a class-based view, you can use the `decorators` attribute: + +``` python hl_lines="2" +class MyView(View): + decorators = [openapi_validated] + + def dispatch_request(self): + return "Welcome home" + +app.add_url_rule('/home', view_func=MyView.as_view('home')) +``` + +## View + +As an alternative to the decorator-based integration, Flask method-based views can be integrated by inheriting from the `FlaskOpenAPIView` class. + +``` python hl_lines="1 3 8" +from openapi_core.contrib.flask.views import FlaskOpenAPIView + +class MyView(FlaskOpenAPIView): + def get(self): + return "Welcome home" + +app.add_url_rule( + '/home', + view_func=MyView.as_view('home', spec), +) +``` + +Additional customization parameters can be passed to the view. + +``` python hl_lines="10" +from openapi_core.contrib.flask.views import FlaskOpenAPIView + +class MyView(FlaskOpenAPIView): + def get(self): + return "Welcome home" + +app.add_url_rule( + '/home', + view_func=MyView.as_view( + 'home', spec, + extra_format_validators=extra_format_validators, + ), +) +``` + +## Request parameters + +In Flask, all unmarshalled request data are provided as the Flask request object's `openapi.parameters` attribute. + +``` python hl_lines="6 7" +from flask.globals import request + +@app.route('/browse//') +@openapi +def browse(id): + browse_id = request.openapi.parameters.path['id'] + page = request.openapi.parameters.query.get('page', 1) + + return f"Browse {browse_id}, page {page}" +``` + +## Low level + +You can use `FlaskOpenAPIRequest` as a Flask request factory: + +```python +from openapi_core.contrib.flask import FlaskOpenAPIRequest + +openapi_request = FlaskOpenAPIRequest(flask_request) +result = openapi.unmarshal_request(openapi_request) +``` + +For the response factory, see the [Werkzeug](werkzeug.md) integration. diff --git a/docs/integrations/index.md b/docs/integrations/index.md new file mode 100644 index 00000000..e54bcfeb --- /dev/null +++ b/docs/integrations/index.md @@ -0,0 +1,3 @@ +# Integrations + +Openapi-core integrates with popular libraries and frameworks. Each integration offers different levels of support to help validate and unmarshal your request and response data. diff --git a/docs/integrations/pyramid.md b/docs/integrations/pyramid.md new file mode 100644 index 00000000..06501f92 --- /dev/null +++ b/docs/integrations/pyramid.md @@ -0,0 +1,3 @@ +# Pyramid + +For more information, see the [pyramid_openapi3](https://github.com/niteoweb/pyramid_openapi3) project. diff --git a/docs/integrations/requests.md b/docs/integrations/requests.md new file mode 100644 index 00000000..2d229740 --- /dev/null +++ b/docs/integrations/requests.md @@ -0,0 +1,50 @@ +# Requests + +This section describes the integration with the [Requests](https://requests.readthedocs.io) library. + +## Low level + +The integration defines classes useful for low-level integration. + +### Request + +Use `RequestsOpenAPIRequest` to create an OpenAPI request from a Requests request: + +``` python +from requests import Request, Session +from openapi_core.contrib.requests import RequestsOpenAPIRequest + +request = Request('POST', url, data=data, headers=headers) +openapi_request = RequestsOpenAPIRequest(request) +openapi.validate_request(openapi_request) +``` + +### Webhook request + +Use `RequestsOpenAPIWebhookRequest` to create an OpenAPI webhook request from a Requests request: + +``` python +from requests import Request, Session +from openapi_core.contrib.requests import RequestsOpenAPIWebhookRequest + +request = Request('POST', url, data=data, headers=headers) +openapi_webhook_request = RequestsOpenAPIWebhookRequest(request, "my_webhook") +openapi.validate_request(openapi_webhook_request) +``` + +### Response + +Use `RequestsOpenAPIResponse` to create an OpenAPI response from a Requests response: + +``` python +from requests import Request, Session +from openapi_core.contrib.requests import RequestsOpenAPIResponse + +session = Session() +request = Request('POST', url, data=data, headers=headers) +prepped = session.prepare_request(request) +response = session.send(prepped) +openapi_request = RequestsOpenAPIRequest(request) +openapi_response = RequestsOpenAPIResponse(response) +openapi.validate_response(openapi_request, openapi_response) +``` diff --git a/docs/integrations/starlette.md b/docs/integrations/starlette.md new file mode 100644 index 00000000..1d065499 --- /dev/null +++ b/docs/integrations/starlette.md @@ -0,0 +1,89 @@ +# Starlette + +This section describes integration with the [Starlette](https://www.starlette.io) ASGI framework. + +## Middleware + +Starlette can be integrated using [middleware](https://www.starlette.io/middleware/) to apply OpenAPI validation to your entire application. + +Add `StarletteOpenAPIMiddleware` with the OpenAPI object to your `middleware` list. + +``` python hl_lines="1 6" +from openapi_core.contrib.starlette.middlewares import StarletteOpenAPIMiddleware +from starlette.applications import Starlette +from starlette.middleware import Middleware + +middleware = [ + Middleware(StarletteOpenAPIMiddleware, openapi=openapi), +] + +app = Starlette( + # ... + middleware=middleware, +) +``` + +After that, all your requests and responses will be validated. + +You also have access to the unmarshalled result object with all unmarshalled request data through the `openapi` scope of the request object. + +``` python +async def homepage(request): + # get parameters object with path, query, cookies, and headers parameters + unmarshalled_params = request.scope["openapi"].parameters + # or specific location parameters + unmarshalled_path_params = request.scope["openapi"].parameters.path + + # get body + unmarshalled_body = request.scope["openapi"].body + + # get security data + unmarshalled_security = request.scope["openapi"].security +``` + +### Response validation + +You can skip the response validation process by setting `response_cls` to `None`. + +``` python hl_lines="2" +middleware = [ + Middleware(StarletteOpenAPIMiddleware, openapi=openapi, response_cls=None), +] + +app = Starlette( + # ... + middleware=middleware, +) +``` + +## Low level + +The integration defines classes useful for low-level integration. + +### Request + +Use `StarletteOpenAPIRequest` to create an OpenAPI request from a Starlette request: + +``` python +from openapi_core.contrib.starlette import StarletteOpenAPIRequest + +async def homepage(request): + openapi_request = StarletteOpenAPIRequest(request) + result = openapi.unmarshal_request(openapi_request) + return JSONResponse({'hello': 'world'}) +``` + +### Response + +Use `StarletteOpenAPIResponse` to create an OpenAPI response from a Starlette response: + +``` python +from openapi_core.contrib.starlette import StarletteOpenAPIResponse + +async def homepage(request): + response = JSONResponse({'hello': 'world'}) + openapi_request = StarletteOpenAPIRequest(request) + openapi_response = StarletteOpenAPIResponse(response) + openapi.validate_response(openapi_request, openapi_response) + return response +``` diff --git a/docs/integrations/tornado.md b/docs/integrations/tornado.md new file mode 100644 index 00000000..cecbbf2d --- /dev/null +++ b/docs/integrations/tornado.md @@ -0,0 +1,3 @@ +# Tornado + +For more information, see the [tornado-openapi3](https://github.com/correl/tornado-openapi3) project. diff --git a/docs/integrations/werkzeug.md b/docs/integrations/werkzeug.md new file mode 100644 index 00000000..ca49bc05 --- /dev/null +++ b/docs/integrations/werkzeug.md @@ -0,0 +1,38 @@ +# Werkzeug + +This section describes the integration with [Werkzeug](https://werkzeug.palletsprojects.com), a WSGI web application library. + +## Low level + +The integration defines classes useful for low-level integration. + +### Request + +Use `WerkzeugOpenAPIRequest` to create an OpenAPI request from a Werkzeug request: + +``` python +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIRequest + +def application(environ, start_response): + request = Request(environ) + openapi_request = WerkzeugOpenAPIRequest(request) + openapi.validate_request(openapi_request) + response = Response("Hello world", mimetype='text/plain') + return response(environ, start_response) +``` + +### Response + +Use `WerkzeugOpenAPIResponse` to create an OpenAPI response from a Werkzeug response: + +``` python +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIResponse + +def application(environ, start_response): + request = Request(environ) + response = Response("Hello world", mimetype='text/plain') + openapi_request = WerkzeugOpenAPIRequest(request) + openapi_response = WerkzeugOpenAPIResponse(response) + openapi.validate_response(openapi_request, openapi_response) + return response(environ, start_response) +``` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f510..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/reference/configurations.md b/docs/reference/configurations.md new file mode 100644 index 00000000..91d2e908 --- /dev/null +++ b/docs/reference/configurations.md @@ -0,0 +1,3 @@ +# `Config` class + +::: openapi_core.Config diff --git a/docs/reference/datatypes.md b/docs/reference/datatypes.md new file mode 100644 index 00000000..1ab3f8b5 --- /dev/null +++ b/docs/reference/datatypes.md @@ -0,0 +1,5 @@ +# Datatypes + +::: openapi_core.unmarshalling.request.datatypes.RequestUnmarshalResult + +::: openapi_core.unmarshalling.response.datatypes.ResponseUnmarshalResult diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000..d3c81f27 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,3 @@ +# Reference + +Documentation with information on functions, classes, methods, and all other parts of the OpenAPI-core public API. diff --git a/docs/reference/openapi.md b/docs/reference/openapi.md new file mode 100644 index 00000000..6fa1e7d5 --- /dev/null +++ b/docs/reference/openapi.md @@ -0,0 +1,14 @@ +# `OpenAPI` class + +::: openapi_core.OpenAPI + options: + members: + - __init__ + - from_dict + - from_path + - from_file_path + - from_file + - unmarshal_request + - unmarshal_response + - validate_request + - validate_response diff --git a/docs/reference/protocols.md b/docs/reference/protocols.md new file mode 100644 index 00000000..849ec67d --- /dev/null +++ b/docs/reference/protocols.md @@ -0,0 +1,3 @@ +# `Request`, `WebhookRequest` and `Response` protocols + +::: openapi_core.protocols diff --git a/docs/reference/types.md b/docs/reference/types.md new file mode 100644 index 00000000..d5b2a85c --- /dev/null +++ b/docs/reference/types.md @@ -0,0 +1,3 @@ +# Types + +::: openapi_core.types diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 82133027..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinx -sphinx_rtd_theme diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..f9315c3a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,40 @@ +--- +hide: + - navigation +--- + +# Security + +Openapi-core provides easy access to security data for authentication and authorization processes. + +Supported security schemes: + +- http – for Basic and Bearer HTTP authentication schemes +- apiKey – for API keys and cookie authentication + +Here's an example with `BasicAuth` and `ApiKeyAuth` security schemes: + +```yaml +security: + - BasicAuth: [] + - ApiKeyAuth: [] +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key +``` + +Security scheme data is accessible from the `security` attribute of the `RequestUnmarshalResult` object. + +```python +# Get basic auth decoded credentials +result.security['BasicAuth'] + +# Get API key +result.security['ApiKeyAuth'] +``` diff --git a/docs/unmarshalling.md b/docs/unmarshalling.md new file mode 100644 index 00000000..334114fa --- /dev/null +++ b/docs/unmarshalling.md @@ -0,0 +1,93 @@ +--- +hide: + - navigation +--- + +# Unmarshalling + +Unmarshalling is the process of converting a primitive schema type value into a higher-level object based on a `format` keyword. All request/response data that can be described by a schema in the OpenAPI specification can be unmarshalled. + +Unmarshallers first validate data against the provided schema (See [Validation](validation.md)). + +Openapi-core comes with a set of built-in format unmarshallers: + +- `date` - converts a string into a date object, +- `date-time` - converts a string into a datetime object, +- `binary` - converts a string into a byte object, +- `uuid` - converts a string into a UUID object, +- `byte` - decodes a Base64-encoded string. + +You can also define your own format unmarshallers (See [Extra Format Unmarshallers](configuration.md#extra-format-unmarshallers)). + +## Request unmarshalling + +Use the `unmarshal_request` method to validate and unmarshal request data against a given spec. By default, the OpenAPI spec version is detected: + +```python +# raises an error if the request is invalid +result = openapi.unmarshal_request(request) +``` + +The request object should implement the OpenAPI Request protocol (See [Integrations](integrations/index.md)). + +!!! note + + The Webhooks feature is part of OpenAPI v3.1 only. + +Use the same method to validate and unmarshal webhook request data against a given spec. + +```python +# raises an error if the request is invalid +result = openapi.unmarshal_request(webhook_request) +``` + +The webhook request object should implement the OpenAPI WebhookRequest protocol (See [Integrations](integrations/index.md)). + +Retrieve validated and unmarshalled request data: + +```python +# get parameters +path_params = result.parameters.path +query_params = result.parameters.query +cookies_params = result.parameters.cookies +headers_params = result.parameters.headers +# get body +body = result.body +# get security data +security = result.security +``` + +You can also define your own request unmarshaller (See [Request Unmarshaller](configuration.md#request-unmarshaller)). + +## Response unmarshalling + +Use the `unmarshal_response` method to validate and unmarshal response data against a given spec. By default, the OpenAPI spec version is detected: + +```python +# raises an error if the response is invalid +result = openapi.unmarshal_response(request, response) +``` + +The response object should implement the OpenAPI Response protocol (See [Integrations](integrations/index.md)). + +!!! note + + The Webhooks feature is part of OpenAPI v3.1 only. + +Use the same method to validate and unmarshal response data from a webhook request against a given spec. + +```python +# raises an error if the request is invalid +result = openapi.unmarshal_response(webhook_request, response) +``` + +Retrieve validated and unmarshalled response data: + +```python +# get headers +headers = result.headers +# get data +data = result.data +``` + +You can also define your own response unmarshaller (See [Response Unmarshaller](configuration.md#response-unmarshaller)). diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index b54f85da..00000000 --- a/docs/usage.rst +++ /dev/null @@ -1,116 +0,0 @@ -Usage -===== - -Firstly create your specification: - -.. code-block:: python - - from openapi_core import create_spec - - spec = create_spec(spec_dict) - - -Request -------- - -Now you can use it to validate requests - -.. code-block:: python - - from openapi_core.validation.request.validators import RequestValidator - - validator = RequestValidator(spec) - result = validator.validate(request) - - # raise errors if request invalid - result.raise_for_errors() - - # get list of errors - errors = result.errors - -and unmarshal request data from validation result - -.. code-block:: python - - # get parameters object with path, query, cookies and headers parameters - validated_params = result.parameters - # or specific parameters - validated_path_params = result.parameters.path - - # get body - validated_body = result.body - - # get security data - validated_security = result.security - -Request object should be instance of OpenAPIRequest class (See :doc:`integrations`). - -Response --------- - -You can also validate responses - -.. code-block:: python - - from openapi_core.validation.response.validators import ResponseValidator - - validator = ResponseValidator(spec) - result = validator.validate(request, response) - - # raise errors if response invalid - result.raise_for_errors() - - # get list of errors - errors = result.errors - -and unmarshal response data from validation result - -.. code-block:: python - - # get headers - validated_headers = result.headers - - # get data - validated_data = result.data - -Response object should be instance of OpenAPIResponse class (See :doc:`integrations`). - -Security --------- - -openapi-core supports security for authentication and authorization process. Security data for security schemas are accessible from `security` attribute of `RequestValidationResult` object. - -For given security specification: - -.. code-block:: yaml - - security: - - BasicAuth: [] - - ApiKeyAuth: [] - components: - securitySchemes: - BasicAuth: - type: http - scheme: basic - ApiKeyAuth: - type: apiKey - in: header - name: X-API-Key - -you can access your security data the following: - -.. code-block:: python - - result = validator.validate(request) - - # get basic auth decoded credentials - result.security['BasicAuth'] - - # get api key - result.security['ApiKeyAuth'] - -Supported security types: - -* http – for Basic and Bearer HTTP authentications schemes -* apiKey – for API keys and cookie authentication - diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 00000000..5d40480f --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,68 @@ +--- +hide: + - navigation +--- + +# Validation + +Validation is a process to validate request/response data under a given schema defined in the OpenAPI specification. + +Additionally, openapi-core uses the `format` keyword to check if primitive types conform to defined formats. + +Such valid formats can be further unmarshalled (See [Unmarshalling](unmarshalling.md)). + +Depending on the OpenAPI version, openapi-core comes with a set of built-in format validators such as: `date`, `date-time`, `binary`, `uuid`, or `byte`. + +You can also define your own format validators (See [Extra Format Validators](configuration.md#extra-format-validators)). + +## Request validation + +Use the `validate_request` method to validate request data against a given spec. By default, the OpenAPI spec version is detected: + +```python +# raises error if request is invalid +openapi.validate_request(request) +``` + +The request object should implement the OpenAPI Request protocol (See [Integrations](integrations/index.md)). + +!!! note + + The Webhooks feature is part of OpenAPI v3.1 only + +Use the same method to validate webhook request data against a given spec. + +```python +# raises error if request is invalid +openapi.validate_request(webhook_request) +``` + +The webhook request object should implement the OpenAPI WebhookRequest protocol (See [Integrations](integrations/index.md)). + +You can also define your own request validator (See [Request Validator](configuration.md#request-validator)). + +## Response validation + +Use the `validate_response` function to validate response data against a given spec. By default, the OpenAPI spec version is detected: + +```python +from openapi_core import validate_response + +# raises error if response is invalid +openapi.validate_response(request, response) +``` + +The response object should implement the OpenAPI Response protocol (See [Integrations](integrations/index.md)). + +!!! note + + The Webhooks feature is part of OpenAPI v3.1 only + +Use the same function to validate response data from a webhook request against a given spec. + +```python +# raises error if request is invalid +openapi.validate_response(webhook_request, response) +``` + +You can also define your own response validator (See [Response Validator](configuration.md#response-validator)). diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..56ddcd8e --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,109 @@ +site_name: OpenAPI-core +site_description: OpenAPI for Python +site_url: https://openapi-core.readthedocs.io/ +theme: + name: material + icon: + repo: fontawesome/brands/github-alt + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/toggle-switch + name: Switch to light mode + - media: '(prefers-color-scheme: light)' + scheme: default + primary: lime + accent: amber + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - media: '(prefers-color-scheme: dark)' + scheme: slate + primary: lime + accent: amber + toggle: + icon: material/toggle-switch-off + name: Switch to system preference + features: + - content.code.annotate + - content.code.copy + - content.footnote.tooltips + - content.tabs.link + - content.tooltips + - navigation.footer + - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - navigation.tracking + - search.highlight + - search.share + - search.suggest + - toc.follow +repo_name: python-openapi/openapi-core +repo_url: https://github.com/python-openapi/openapi-core +plugins: + - mkdocstrings: + handlers: + python: + options: + extensions: + - griffe_typingdoc + show_root_heading: true + show_if_no_docstring: true + inherited_members: true + members_order: source + unwrap_annotated: true + docstring_section_style: spacy + separate_signature: true + signature_crossrefs: true + show_category_heading: true + show_signature_annotations: true + show_symbol_type_heading: true + show_symbol_type_toc: true +nav: + - OpenAPI-core: index.md + - unmarshalling.md + - validation.md + - Integrations: + - integrations/index.md + - integrations/aiohttp.md + - integrations/bottle.md + - integrations/django.md + - integrations/falcon.md + - integrations/fastapi.md + - integrations/flask.md + - integrations/pyramid.md + - integrations/requests.md + - integrations/starlette.md + - integrations/tornado.md + - integrations/werkzeug.md + - configuration.md + - security.md + - extensions.md + - Reference: + - reference/index.md + - reference/openapi.md + - reference/configurations.md + - reference/datatypes.md + - reference/protocols.md + - reference/types.md + - contributing.md +markdown_extensions: + - admonition + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + line_spans: __span + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true +extra: + analytics: + provider: google + property: G-J6T05Z51NY diff --git a/openapi_core/__init__.py b/openapi_core/__init__.py index 3b2d1f47..79a5bea1 100644 --- a/openapi_core/__init__.py +++ b/openapi_core/__init__.py @@ -1,15 +1,81 @@ -# -*- coding: utf-8 -*- """OpenAPI core module""" -from openapi_core.shortcuts import ( - create_spec, validate_parameters, validate_body, validate_data, -) -__author__ = 'Artur Maciag' -__email__ = 'maciag.artur@gmail.com' -__version__ = '0.13.7' -__url__ = 'https://github.com/p1c2u/openapi-core' -__license__ = 'BSD 3-Clause License' +from openapi_core.app import OpenAPI +from openapi_core.configurations import Config +from openapi_core.shortcuts import unmarshal_apicall_request +from openapi_core.shortcuts import unmarshal_apicall_response +from openapi_core.shortcuts import unmarshal_request +from openapi_core.shortcuts import unmarshal_response +from openapi_core.shortcuts import unmarshal_webhook_request +from openapi_core.shortcuts import unmarshal_webhook_response +from openapi_core.shortcuts import validate_apicall_request +from openapi_core.shortcuts import validate_apicall_response +from openapi_core.shortcuts import validate_request +from openapi_core.shortcuts import validate_response +from openapi_core.shortcuts import validate_webhook_request +from openapi_core.shortcuts import validate_webhook_response +from openapi_core.spec.paths import Spec +from openapi_core.unmarshalling.request import V3RequestUnmarshaller +from openapi_core.unmarshalling.request import V3WebhookRequestUnmarshaller +from openapi_core.unmarshalling.request import V30RequestUnmarshaller +from openapi_core.unmarshalling.request import V31RequestUnmarshaller +from openapi_core.unmarshalling.request import V31WebhookRequestUnmarshaller +from openapi_core.unmarshalling.response import V3ResponseUnmarshaller +from openapi_core.unmarshalling.response import V3WebhookResponseUnmarshaller +from openapi_core.unmarshalling.response import V30ResponseUnmarshaller +from openapi_core.unmarshalling.response import V31ResponseUnmarshaller +from openapi_core.unmarshalling.response import V31WebhookResponseUnmarshaller +from openapi_core.validation.request import V3RequestValidator +from openapi_core.validation.request import V3WebhookRequestValidator +from openapi_core.validation.request import V30RequestValidator +from openapi_core.validation.request import V31RequestValidator +from openapi_core.validation.request import V31WebhookRequestValidator +from openapi_core.validation.response import V3ResponseValidator +from openapi_core.validation.response import V3WebhookResponseValidator +from openapi_core.validation.response import V30ResponseValidator +from openapi_core.validation.response import V31ResponseValidator +from openapi_core.validation.response import V31WebhookResponseValidator + +__author__ = "Artur Maciag" +__email__ = "maciag.artur@gmail.com" +__version__ = "0.19.5" +__url__ = "https://github.com/python-openapi/openapi-core" +__license__ = "BSD 3-Clause License" __all__ = [ - 'create_spec', 'validate_parameters', 'validate_body', 'validate_data', + "OpenAPI", + "Config", + "Spec", + "unmarshal_request", + "unmarshal_response", + "unmarshal_apicall_request", + "unmarshal_webhook_request", + "unmarshal_apicall_response", + "unmarshal_webhook_response", + "validate_apicall_request", + "validate_webhook_request", + "validate_apicall_response", + "validate_webhook_response", + "validate_request", + "validate_response", + "V30RequestUnmarshaller", + "V30ResponseUnmarshaller", + "V31RequestUnmarshaller", + "V31ResponseUnmarshaller", + "V31WebhookRequestUnmarshaller", + "V31WebhookResponseUnmarshaller", + "V3RequestUnmarshaller", + "V3ResponseUnmarshaller", + "V3WebhookRequestUnmarshaller", + "V3WebhookResponseUnmarshaller", + "V30RequestValidator", + "V30ResponseValidator", + "V31RequestValidator", + "V31ResponseValidator", + "V31WebhookRequestValidator", + "V31WebhookResponseValidator", + "V3RequestValidator", + "V3ResponseValidator", + "V3WebhookRequestValidator", + "V3WebhookResponseValidator", ] diff --git a/openapi_core/app.py b/openapi_core/app.py new file mode 100644 index 00000000..fcba771c --- /dev/null +++ b/openapi_core/app.py @@ -0,0 +1,823 @@ +"""OpenAPI core app module""" + +from functools import cached_property +from pathlib import Path +from typing import Optional + +from jsonschema._utils import Unset +from jsonschema.validators import _UNSET +from jsonschema_path import SchemaPath +from jsonschema_path.handlers.protocols import SupportsRead +from jsonschema_path.typing import Schema +from openapi_spec_validator import validate +from openapi_spec_validator.validation.exceptions import ValidatorDetectError +from openapi_spec_validator.versions.datatypes import SpecVersion +from openapi_spec_validator.versions.exceptions import OpenAPIVersionNotFound +from openapi_spec_validator.versions.shortcuts import get_spec_version +from typing_extensions import Annotated +from typing_extensions import Doc + +from openapi_core.configurations import Config +from openapi_core.exceptions import SpecError +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.types import AnyRequest +from openapi_core.unmarshalling.request import ( + UNMARSHALLERS as REQUEST_UNMARSHALLERS, +) +from openapi_core.unmarshalling.request import ( + WEBHOOK_UNMARSHALLERS as WEBHOOK_REQUEST_UNMARSHALLERS, +) +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.request.protocols import RequestUnmarshaller +from openapi_core.unmarshalling.request.protocols import ( + WebhookRequestUnmarshaller, +) +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType +from openapi_core.unmarshalling.request.types import ( + WebhookRequestUnmarshallerType, +) +from openapi_core.unmarshalling.response import ( + UNMARSHALLERS as RESPONSE_UNMARSHALLERS, +) +from openapi_core.unmarshalling.response import ( + WEBHOOK_UNMARSHALLERS as WEBHOOK_RESPONSE_UNMARSHALLERS, +) +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.unmarshalling.response.protocols import ResponseUnmarshaller +from openapi_core.unmarshalling.response.protocols import ( + WebhookResponseUnmarshaller, +) +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType +from openapi_core.unmarshalling.response.types import ( + WebhookResponseUnmarshallerType, +) +from openapi_core.validation.request import VALIDATORS as REQUEST_VALIDATORS +from openapi_core.validation.request import ( + WEBHOOK_VALIDATORS as WEBHOOK_REQUEST_VALIDATORS, +) +from openapi_core.validation.request.protocols import RequestValidator +from openapi_core.validation.request.protocols import WebhookRequestValidator +from openapi_core.validation.request.types import RequestValidatorType +from openapi_core.validation.request.types import WebhookRequestValidatorType +from openapi_core.validation.response import VALIDATORS as RESPONSE_VALIDATORS +from openapi_core.validation.response import ( + WEBHOOK_VALIDATORS as WEBHOOK_RESPONSE_VALIDATORS, +) +from openapi_core.validation.response.protocols import ResponseValidator +from openapi_core.validation.response.protocols import WebhookResponseValidator +from openapi_core.validation.response.types import ResponseValidatorType +from openapi_core.validation.response.types import WebhookResponseValidatorType + + +class OpenAPI: + """`OpenAPI` application class, the main entrypoint class for OpenAPI-core. + + OpenAPI can be created in multiple ways: from existing memory data or from storage such as local disk via ``from_*()`` APIs + + Read more information, in the + [OpenAPI-core docs for First Steps](https://openapi-core.readthedocs.io/#first-steps). + + Examples: + You can import the OpenAPI class directly from openapi_core: + + Create an OpenAPI from a dictionary: + + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_dict(spec) + ``` + + Create an OpenAPI from a path object: + + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_path(path) + ``` + + Create an OpenAPI from a file path: + + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_file_path('spec.yaml') + ``` + + Create an OpenAPI from a file object: + + ```python + from openapi_core import OpenAPI + + with open('spec.yaml') as f: + app = OpenAPI.from_file(f) + ``` + + """ + + def __init__( + self, + spec: Annotated[ + SchemaPath, + Doc( + """ + OpenAPI specification schema path object. + """ + ), + ], + config: Annotated[ + Optional[Config], + Doc( + """ + Configuration object for the OpenAPI application. + """ + ), + ] = None, + ): + if not isinstance(spec, SchemaPath): + raise TypeError("'spec' argument is not type of SchemaPath") + + self.spec = spec + self.config = config or Config() + + self.check_spec() + + @classmethod + def from_dict( + cls, + data: Annotated[ + Schema, + Doc( + """ + Dictionary representing the OpenAPI specification. + """ + ), + ], + config: Annotated[ + Optional[Config], + Doc( + """ + Configuration object for the OpenAPI application. + """ + ), + ] = None, + base_uri: Annotated[ + str, + Doc( + """ + Base URI for the OpenAPI specification. + """ + ), + ] = "", + ) -> "OpenAPI": + """Creates an `OpenAPI` from a dictionary. + + Example: + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_dict(spec) + ``` + + Returns: + OpenAPI: An instance of the OpenAPI class. + """ + sp = SchemaPath.from_dict(data, base_uri=base_uri) + return cls(sp, config=config) + + @classmethod + def from_path( + cls, + path: Annotated[ + Path, + Doc( + """ + Path object representing the OpenAPI specification file. + """ + ), + ], + config: Annotated[ + Optional[Config], + Doc( + """ + Configuration object for the OpenAPI application. + """ + ), + ] = None, + ) -> "OpenAPI": + """Creates an `OpenAPI` from a [Path object](https://docs.python.org/3/library/pathlib.html#pathlib.Path). + + Example: + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_path(path) + ``` + + Returns: + OpenAPI: An instance of the OpenAPI class. + """ + sp = SchemaPath.from_path(path) + return cls(sp, config=config) + + @classmethod + def from_file_path( + cls, + file_path: Annotated[ + str, + Doc( + """ + File path string representing the OpenAPI specification file. + """ + ), + ], + config: Annotated[ + Optional[Config], + Doc( + """ + Configuration object for the OpenAPI application. + """ + ), + ] = None, + ) -> "OpenAPI": + """Creates an `OpenAPI` from a file path string. + + Example: + ```python + from openapi_core import OpenAPI + + app = OpenAPI.from_file_path('spec.yaml') + ``` + + Returns: + OpenAPI: An instance of the OpenAPI class. + """ + sp = SchemaPath.from_file_path(file_path) + return cls(sp, config=config) + + @classmethod + def from_file( + cls, + fileobj: Annotated[ + SupportsRead, + Doc( + """ + File object representing the OpenAPI specification file. + """ + ), + ], + config: Annotated[ + Optional[Config], + Doc( + """ + Configuration object for the OpenAPI application. + """ + ), + ] = None, + base_uri: Annotated[ + str, + Doc( + """ + Base URI for the OpenAPI specification. + """ + ), + ] = "", + ) -> "OpenAPI": + """Creates an `OpenAPI` from a [file object](https://docs.python.org/3/glossary.html#term-file-object). + + Example: + ```python + from openapi_core import OpenAPI + + with open('spec.yaml') as f: + app = OpenAPI.from_file(f) + ``` + + Returns: + OpenAPI: An instance of the OpenAPI class. + """ + sp = SchemaPath.from_file(fileobj, base_uri=base_uri) + return cls(sp, config=config) + + def _get_version(self) -> SpecVersion: + try: + return get_spec_version(self.spec.contents()) + # backward compatibility + except OpenAPIVersionNotFound: + raise SpecError("Spec schema version not detected") + + def check_spec(self) -> None: + if self.config.spec_validator_cls is None: + return + + cls = None + if self.config.spec_validator_cls is not _UNSET: + cls = self.config.spec_validator_cls + + try: + validate( + self.spec.contents(), + base_uri=self.config.spec_base_uri + or self.spec.accessor.resolver._base_uri, # type: ignore[attr-defined] + cls=cls, + ) + except ValidatorDetectError: + raise SpecError("spec not detected") + + @property + def version(self) -> SpecVersion: + return self._get_version() + + @cached_property + def request_validator_cls(self) -> Optional[RequestValidatorType]: + if not isinstance(self.config.request_validator_cls, Unset): + return self.config.request_validator_cls + return REQUEST_VALIDATORS.get(self.version) + + @cached_property + def response_validator_cls(self) -> Optional[ResponseValidatorType]: + if not isinstance(self.config.response_validator_cls, Unset): + return self.config.response_validator_cls + return RESPONSE_VALIDATORS.get(self.version) + + @cached_property + def webhook_request_validator_cls( + self, + ) -> Optional[WebhookRequestValidatorType]: + if not isinstance(self.config.webhook_request_validator_cls, Unset): + return self.config.webhook_request_validator_cls + return WEBHOOK_REQUEST_VALIDATORS.get(self.version) + + @cached_property + def webhook_response_validator_cls( + self, + ) -> Optional[WebhookResponseValidatorType]: + if not isinstance(self.config.webhook_response_validator_cls, Unset): + return self.config.webhook_response_validator_cls + return WEBHOOK_RESPONSE_VALIDATORS.get(self.version) + + @cached_property + def request_unmarshaller_cls(self) -> Optional[RequestUnmarshallerType]: + if not isinstance(self.config.request_unmarshaller_cls, Unset): + return self.config.request_unmarshaller_cls + return REQUEST_UNMARSHALLERS.get(self.version) + + @cached_property + def response_unmarshaller_cls(self) -> Optional[ResponseUnmarshallerType]: + if not isinstance(self.config.response_unmarshaller_cls, Unset): + return self.config.response_unmarshaller_cls + return RESPONSE_UNMARSHALLERS.get(self.version) + + @cached_property + def webhook_request_unmarshaller_cls( + self, + ) -> Optional[WebhookRequestUnmarshallerType]: + if not isinstance(self.config.webhook_request_unmarshaller_cls, Unset): + return self.config.webhook_request_unmarshaller_cls + return WEBHOOK_REQUEST_UNMARSHALLERS.get(self.version) + + @cached_property + def webhook_response_unmarshaller_cls( + self, + ) -> Optional[WebhookResponseUnmarshallerType]: + if not isinstance( + self.config.webhook_response_unmarshaller_cls, Unset + ): + return self.config.webhook_response_unmarshaller_cls + return WEBHOOK_RESPONSE_UNMARSHALLERS.get(self.version) + + @cached_property + def request_validator(self) -> RequestValidator: + if self.request_validator_cls is None: + raise SpecError("Validator class not found") + return self.request_validator_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + security_provider_factory=self.config.security_provider_factory, + ) + + @cached_property + def response_validator(self) -> ResponseValidator: + if self.response_validator_cls is None: + raise SpecError("Validator class not found") + return self.response_validator_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + ) + + @cached_property + def webhook_request_validator(self) -> WebhookRequestValidator: + if self.webhook_request_validator_cls is None: + raise SpecError("Validator class not found") + return self.webhook_request_validator_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.webhook_path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + security_provider_factory=self.config.security_provider_factory, + ) + + @cached_property + def webhook_response_validator(self) -> WebhookResponseValidator: + if self.webhook_response_validator_cls is None: + raise SpecError("Validator class not found") + return self.webhook_response_validator_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.webhook_path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + ) + + @cached_property + def request_unmarshaller(self) -> RequestUnmarshaller: + if self.request_unmarshaller_cls is None: + raise SpecError("Unmarshaller class not found") + return self.request_unmarshaller_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + security_provider_factory=self.config.security_provider_factory, + schema_unmarshallers_factory=self.config.schema_unmarshallers_factory, + extra_format_unmarshallers=self.config.extra_format_unmarshallers, + ) + + @cached_property + def response_unmarshaller(self) -> ResponseUnmarshaller: + if self.response_unmarshaller_cls is None: + raise SpecError("Unmarshaller class not found") + return self.response_unmarshaller_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + schema_unmarshallers_factory=self.config.schema_unmarshallers_factory, + extra_format_unmarshallers=self.config.extra_format_unmarshallers, + ) + + @cached_property + def webhook_request_unmarshaller(self) -> WebhookRequestUnmarshaller: + if self.webhook_request_unmarshaller_cls is None: + raise SpecError("Unmarshaller class not found") + return self.webhook_request_unmarshaller_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.webhook_path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + security_provider_factory=self.config.security_provider_factory, + schema_unmarshallers_factory=self.config.schema_unmarshallers_factory, + extra_format_unmarshallers=self.config.extra_format_unmarshallers, + ) + + @cached_property + def webhook_response_unmarshaller(self) -> WebhookResponseUnmarshaller: + if self.webhook_response_unmarshaller_cls is None: + raise SpecError("Unmarshaller class not found") + return self.webhook_response_unmarshaller_cls( + self.spec, + base_url=self.config.server_base_url, + style_deserializers_factory=self.config.style_deserializers_factory, + media_type_deserializers_factory=self.config.media_type_deserializers_factory, + schema_casters_factory=self.config.schema_casters_factory, + schema_validators_factory=self.config.schema_validators_factory, + path_finder_cls=self.config.webhook_path_finder_cls, + spec_validator_cls=self.config.spec_validator_cls, + extra_format_validators=self.config.extra_format_validators, + extra_media_type_deserializers=self.config.extra_media_type_deserializers, + schema_unmarshallers_factory=self.config.schema_unmarshallers_factory, + extra_format_unmarshallers=self.config.extra_format_unmarshallers, + ) + + def validate_request( + self, + request: Annotated[ + AnyRequest, + Doc( + """ + Request object to be validated. + """ + ), + ], + ) -> None: + """Validates the given request object. + + Args: + request (AnyRequest): Request object to be validated. + + Raises: + TypeError: If the request object is not of the expected type. + SpecError: If the validator class is not found. + """ + if isinstance(request, WebhookRequest): + self.validate_webhook_request(request) + else: + self.validate_apicall_request(request) + + def validate_response( + self, + request: Annotated[ + AnyRequest, + Doc( + """ + Request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + Response object to be validated. + """ + ), + ], + ) -> None: + """Validates the given response object associated with the request. + + Args: + request (AnyRequest): Request object associated with the response. + response (Response): Response object to be validated. + + Raises: + TypeError: If the request or response object is not of the expected type. + SpecError: If the validator class is not found. + """ + if isinstance(request, WebhookRequest): + self.validate_webhook_response(request, response) + else: + self.validate_apicall_response(request, response) + + def validate_apicall_request( + self, + request: Annotated[ + Request, + Doc( + """ + API call request object to be validated. + """ + ), + ], + ) -> None: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + self.request_validator.validate(request) + + def validate_apicall_response( + self, + request: Annotated[ + Request, + Doc( + """ + API call request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + API call response object to be validated. + """ + ), + ], + ) -> None: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + self.response_validator.validate(request, response) + + def validate_webhook_request( + self, + request: Annotated[ + WebhookRequest, + Doc( + """ + Webhook request object to be validated. + """ + ), + ], + ) -> None: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + self.webhook_request_validator.validate(request) + + def validate_webhook_response( + self, + request: Annotated[ + WebhookRequest, + Doc( + """ + Webhook request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + Webhook response object to be validated. + """ + ), + ], + ) -> None: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + self.webhook_response_validator.validate(request, response) + + def unmarshal_request( + self, + request: Annotated[ + AnyRequest, + Doc( + """ + Request object to be unmarshalled. + """ + ), + ], + ) -> RequestUnmarshalResult: + """Unmarshals the given request object. + + Args: + request (AnyRequest): Request object to be unmarshalled. + + Returns: + RequestUnmarshalResult: The result of the unmarshalling process. + + Raises: + TypeError: If the request object is not of the expected type. + SpecError: If the unmarshaller class is not found. + """ + if isinstance(request, WebhookRequest): + return self.unmarshal_webhook_request(request) + else: + return self.unmarshal_apicall_request(request) + + def unmarshal_response( + self, + request: Annotated[ + AnyRequest, + Doc( + """ + Request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + Response object to be unmarshalled. + """ + ), + ], + ) -> ResponseUnmarshalResult: + """Unmarshals the given response object associated with the request. + + Args: + request (AnyRequest): Request object associated with the response. + response (Response): Response object to be unmarshalled. + + Returns: + ResponseUnmarshalResult: The result of the unmarshalling process. + + Raises: + TypeError: If the request or response object is not of the expected type. + SpecError: If the unmarshaller class is not found. + """ + if isinstance(request, WebhookRequest): + return self.unmarshal_webhook_response(request, response) + else: + return self.unmarshal_apicall_response(request, response) + + def unmarshal_apicall_request( + self, + request: Annotated[ + Request, + Doc( + """ + API call request object to be unmarshalled. + """ + ), + ], + ) -> RequestUnmarshalResult: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + return self.request_unmarshaller.unmarshal(request) + + def unmarshal_apicall_response( + self, + request: Annotated[ + Request, + Doc( + """ + API call request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + API call response object to be unmarshalled. + """ + ), + ], + ) -> ResponseUnmarshalResult: + if not isinstance(request, Request): + raise TypeError("'request' argument is not type of Request") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + return self.response_unmarshaller.unmarshal(request, response) + + def unmarshal_webhook_request( + self, + request: Annotated[ + WebhookRequest, + Doc( + """ + Webhook request object to be unmarshalled. + """ + ), + ], + ) -> RequestUnmarshalResult: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + return self.webhook_request_unmarshaller.unmarshal(request) + + def unmarshal_webhook_response( + self, + request: Annotated[ + WebhookRequest, + Doc( + """ + Webhook request object associated with the response. + """ + ), + ], + response: Annotated[ + Response, + Doc( + """ + Webhook response object to be unmarshalled. + """ + ), + ], + ) -> ResponseUnmarshalResult: + if not isinstance(request, WebhookRequest): + raise TypeError("'request' argument is not type of WebhookRequest") + if not isinstance(response, Response): + raise TypeError("'response' argument is not type of Response") + return self.webhook_response_unmarshaller.unmarshal(request, response) diff --git a/openapi_core/casting/schemas/__init__.py b/openapi_core/casting/schemas/__init__.py index e69de29b..18b1a9e3 100644 --- a/openapi_core/casting/schemas/__init__.py +++ b/openapi_core/casting/schemas/__init__.py @@ -0,0 +1,65 @@ +from collections import OrderedDict + +from openapi_core.casting.schemas.casters import ArrayCaster +from openapi_core.casting.schemas.casters import BooleanCaster +from openapi_core.casting.schemas.casters import IntegerCaster +from openapi_core.casting.schemas.casters import NumberCaster +from openapi_core.casting.schemas.casters import ObjectCaster +from openapi_core.casting.schemas.casters import PrimitiveCaster +from openapi_core.casting.schemas.casters import TypesCaster +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.validation.schemas import ( + oas30_read_schema_validators_factory, +) +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, +) +from openapi_core.validation.schemas import oas31_schema_validators_factory + +__all__ = [ + "oas30_write_schema_casters_factory", + "oas30_read_schema_casters_factory", + "oas31_schema_casters_factory", +] + +oas30_casters_dict = OrderedDict( + [ + ("object", ObjectCaster), + ("array", ArrayCaster), + ("boolean", BooleanCaster), + ("integer", IntegerCaster), + ("number", NumberCaster), + ("string", PrimitiveCaster), + ] +) +oas31_casters_dict = oas30_casters_dict.copy() +oas31_casters_dict.update( + { + "null": PrimitiveCaster, + } +) + +oas30_types_caster = TypesCaster( + oas30_casters_dict, + PrimitiveCaster, +) +oas31_types_caster = TypesCaster( + oas31_casters_dict, + PrimitiveCaster, + multi=PrimitiveCaster, +) + +oas30_write_schema_casters_factory = SchemaCastersFactory( + oas30_write_schema_validators_factory, + oas30_types_caster, +) + +oas30_read_schema_casters_factory = SchemaCastersFactory( + oas30_read_schema_validators_factory, + oas30_types_caster, +) + +oas31_schema_casters_factory = SchemaCastersFactory( + oas31_schema_validators_factory, + oas31_types_caster, +) diff --git a/openapi_core/casting/schemas/casters.py b/openapi_core/casting/schemas/casters.py index 554f36f7..94df492b 100644 --- a/openapi_core/casting/schemas/casters.py +++ b/openapi_core/casting/schemas/casters.py @@ -1,39 +1,235 @@ -from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.schema.schemas.types import NoValue +from typing import Any +from typing import Generic +from typing import Iterable +from typing import List +from typing import Mapping +from typing import Optional +from typing import Type +from typing import TypeVar +from typing import Union + +from jsonschema_path import SchemaPath +from openapi_core.casting.schemas.exceptions import CastError +from openapi_core.schema.schemas import get_properties +from openapi_core.util import forcebool +from openapi_core.validation.schemas.validators import SchemaValidator -class PrimitiveCaster(object): - def __init__(self, schema, caster_callable): +class PrimitiveCaster: + def __init__( + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + schema_caster: "SchemaCaster", + ): self.schema = schema - self.caster_callable = caster_callable + self.schema_validator = schema_validator + self.schema_caster = schema_caster + + def __call__(self, value: Any) -> Any: + return value + + +PrimitiveType = TypeVar("PrimitiveType") + + +class PrimitiveTypeCaster(Generic[PrimitiveType], PrimitiveCaster): + primitive_type: Type[PrimitiveType] = NotImplemented + + def __call__(self, value: Union[str, bytes]) -> Any: + self.validate(value) + + return self.primitive_type(value) # type: ignore [call-arg] + + def validate(self, value: Any) -> None: + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + # if not isinstance(value, (str, bytes)): + # raise ValueError("should cast only from string or bytes") + pass + + +class IntegerCaster(PrimitiveTypeCaster[int]): + primitive_type = int + + +class NumberCaster(PrimitiveTypeCaster[float]): + primitive_type = float + + +class BooleanCaster(PrimitiveTypeCaster[bool]): + primitive_type = bool + + def __call__(self, value: Union[str, bytes]) -> Any: + self.validate(value) + + return self.primitive_type(forcebool(value)) + + def validate(self, value: Any) -> None: + super().validate(value) + + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + if isinstance(value, bool): + return + + if value.lower() not in ["false", "true"]: + raise ValueError("not a boolean format") + + +class ArrayCaster(PrimitiveCaster): + @property + def items_caster(self) -> "SchemaCaster": + # sometimes we don't have any schema i.e. free-form objects + items_schema = self.schema.get("items", SchemaPath.from_dict({})) + return self.schema_caster.evolve(items_schema) + + def __call__(self, value: Any) -> List[Any]: + # str and bytes are not arrays according to the OpenAPI spec + if isinstance(value, (str, bytes)) or not isinstance(value, Iterable): + raise CastError(value, self.schema["type"]) - def __call__(self, value): - if value in (None, NoValue): - return value try: - return self.caster_callable(value) + return list(map(self.items_caster.cast, value)) except (ValueError, TypeError): - raise CastError(value, self.schema.type.value) + raise CastError(value, self.schema["type"]) + + +class ObjectCaster(PrimitiveCaster): + def __call__(self, value: Any) -> Any: + return self._cast_proparties(value) + + def evolve(self, schema: SchemaPath) -> "ObjectCaster": + cls = self.__class__ + + return cls( + schema, + self.schema_validator.evolve(schema), + self.schema_caster.evolve(schema), + ) + + def _cast_proparties(self, value: Any, schema_only: bool = False) -> Any: + if not isinstance(value, dict): + raise CastError(value, self.schema["type"]) + all_of_schemas = self.schema_validator.iter_all_of_schemas(value) + for all_of_schema in all_of_schemas: + all_of_properties = self.evolve(all_of_schema)._cast_proparties( + value, schema_only=True + ) + value.update(all_of_properties) -class DummyCaster(object): + for prop_name, prop_schema in get_properties(self.schema).items(): + try: + prop_value = value[prop_name] + except KeyError: + continue + value[prop_name] = self.schema_caster.evolve(prop_schema).cast( + prop_value + ) + + if schema_only: + return value + + additional_properties = self.schema.getkey( + "additionalProperties", True + ) + if additional_properties is not False: + # free-form object + if additional_properties is True: + additional_prop_schema = SchemaPath.from_dict( + {"nullable": True} + ) + # defined schema + else: + additional_prop_schema = self.schema / "additionalProperties" + additional_prop_caster = self.schema_caster.evolve( + additional_prop_schema + ) + for prop_name, prop_value in value.items(): + if prop_name in value: + continue + value[prop_name] = additional_prop_caster.cast(prop_value) - def __call__(self, value): return value -class ArrayCaster(object): +class TypesCaster: + casters: Mapping[str, Type[PrimitiveCaster]] = {} + multi: Optional[Type[PrimitiveCaster]] = None + + def __init__( + self, + casters: Mapping[str, Type[PrimitiveCaster]], + default: Type[PrimitiveCaster], + multi: Optional[Type[PrimitiveCaster]] = None, + ): + self.casters = casters + self.default = default + self.multi = multi + + def get_caster( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> Type["PrimitiveCaster"]: + if schema_type is None: + return self.default + if isinstance(schema_type, Iterable) and not isinstance( + schema_type, str + ): + if self.multi is None: + raise TypeError("caster does not accept multiple types") + return self.multi + + return self.casters[schema_type] - def __init__(self, schema, casters_factory): + +class SchemaCaster: + def __init__( + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + types_caster: TypesCaster, + ): self.schema = schema - self.casters_factory = casters_factory + self.schema_validator = schema_validator - @property - def items_caster(self): - return self.casters_factory.create(self.schema.items) + self.types_caster = types_caster + + def cast(self, value: Any) -> Any: + # skip casting for nullable in OpenAPI 3.0 + if value is None and self.schema.getkey("nullable", False): + return value + + schema_type = self.schema.getkey("type") + + type_caster = self.get_type_caster(schema_type) - def __call__(self, value): - if value in (None, NoValue): + if value is None: return value - return list(map(self.items_caster, value)) + + try: + return type_caster(value) + except (ValueError, TypeError): + raise CastError(value, schema_type) + + def get_type_caster( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> PrimitiveCaster: + caster_cls = self.types_caster.get_caster(schema_type) + return caster_cls( + self.schema, + self.schema_validator, + self, + ) + + def evolve(self, schema: SchemaPath) -> "SchemaCaster": + cls = self.__class__ + + return cls( + schema, + self.schema_validator.evolve(schema), + self.types_caster, + ) diff --git a/openapi_core/casting/schemas/datatypes.py b/openapi_core/casting/schemas/datatypes.py new file mode 100644 index 00000000..1014bf63 --- /dev/null +++ b/openapi_core/casting/schemas/datatypes.py @@ -0,0 +1,4 @@ +from typing import Any +from typing import Callable + +CasterCallable = Callable[[Any], Any] diff --git a/openapi_core/casting/schemas/exceptions.py b/openapi_core/casting/schemas/exceptions.py index cc10672a..242288d2 100644 --- a/openapi_core/casting/schemas/exceptions.py +++ b/openapi_core/casting/schemas/exceptions.py @@ -1,14 +1,15 @@ -import attr +from dataclasses import dataclass +from typing import Any from openapi_core.exceptions import OpenAPIError -@attr.s(hash=True) +@dataclass class CastError(OpenAPIError): """Schema cast operation error""" - value = attr.ib() - type = attr.ib() - def __str__(self): - return "Failed to cast value {value} to type {type}".format( - value=self.value, type=self.type) + value: Any + type: str + + def __str__(self) -> str: + return f"Failed to cast value to {self.type} type: {self.value}" diff --git a/openapi_core/casting/schemas/factories.py b/openapi_core/casting/schemas/factories.py index 80528892..39c7832b 100644 --- a/openapi_core/casting/schemas/factories.py +++ b/openapi_core/casting/schemas/factories.py @@ -1,31 +1,32 @@ -from openapi_core.schema.schemas.enums import SchemaType +from typing import Optional -from openapi_core.casting.schemas.casters import ( - PrimitiveCaster, DummyCaster, ArrayCaster -) -from openapi_core.casting.schemas.util import forcebool +from jsonschema_path import SchemaPath +from openapi_core.casting.schemas.casters import SchemaCaster +from openapi_core.casting.schemas.casters import TypesCaster +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory -class SchemaCastersFactory(object): - DUMMY_CASTERS = [ - SchemaType.STRING, SchemaType.OBJECT, SchemaType.ANY, - ] - PRIMITIVE_CASTERS = { - SchemaType.INTEGER: int, - SchemaType.NUMBER: float, - SchemaType.BOOLEAN: forcebool, - } - COMPLEX_CASTERS = { - SchemaType.ARRAY: ArrayCaster, - } +class SchemaCastersFactory: + def __init__( + self, + schema_validators_factory: SchemaValidatorsFactory, + types_caster: TypesCaster, + ): + self.schema_validators_factory = schema_validators_factory + self.types_caster = types_caster - def create(self, schema): - if schema.type in self.DUMMY_CASTERS: - return DummyCaster() - elif schema.type in self.PRIMITIVE_CASTERS: - caster_callable = self.PRIMITIVE_CASTERS[schema.type] - return PrimitiveCaster(schema, caster_callable) - elif schema.type in self.COMPLEX_CASTERS: - caster_class = self.COMPLEX_CASTERS[schema.type] - return caster_class(schema, self) + def create( + self, + schema: SchemaPath, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + ) -> SchemaCaster: + schema_validator = self.schema_validators_factory.create( + schema, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + ) + + return SchemaCaster(schema, schema_validator, self.types_caster) diff --git a/openapi_core/casting/schemas/util.py b/openapi_core/casting/schemas/util.py deleted file mode 100644 index bb000a77..00000000 --- a/openapi_core/casting/schemas/util.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OpenAPI core casting schemas util module""" -from distutils.util import strtobool -from six import string_types - - -def forcebool(val): - if isinstance(val, string_types): - val = strtobool(val) - - return bool(val) diff --git a/openapi_core/compat.py b/openapi_core/compat.py deleted file mode 100644 index 53eeadf7..00000000 --- a/openapi_core/compat.py +++ /dev/null @@ -1,12 +0,0 @@ -"""OpenAPI core python 2.7 compatibility module""" -try: - from functools import lru_cache -except ImportError: - from backports.functools_lru_cache import lru_cache - -try: - from functools import partialmethod -except ImportError: - from backports.functools_partialmethod import partialmethod - -__all__ = ['lru_cache', 'partialmethod'] diff --git a/openapi_core/configurations.py b/openapi_core/configurations.py new file mode 100644 index 00000000..9b23eb03 --- /dev/null +++ b/openapi_core/configurations.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass +from typing import Union + +from jsonschema._utils import Unset +from jsonschema.validators import _UNSET +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.unmarshalling.configurations import UnmarshallerConfig +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType +from openapi_core.unmarshalling.request.types import ( + WebhookRequestUnmarshallerType, +) +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType +from openapi_core.unmarshalling.response.types import ( + WebhookResponseUnmarshallerType, +) +from openapi_core.validation.request.types import RequestValidatorType +from openapi_core.validation.request.types import WebhookRequestValidatorType +from openapi_core.validation.response.types import ResponseValidatorType +from openapi_core.validation.response.types import WebhookResponseValidatorType + + +@dataclass +class Config(UnmarshallerConfig): + """OpenAPI configuration dataclass. + + Read more information, in the + [OpenAPI-core docs for Configuration](https://openapi-core.readthedocs.io/configuration/). + + Attributes: + spec_validator_cls: Specification validator class. + spec_base_uri: Specification base URI. Deprecated, use base_uri parameter in OpenAPI.from_dict and OpenAPI.from_file if you want to define it. + request_validator_cls: Request validator class. + response_validator_cls: Response validator class. + webhook_request_validator_cls: Webhook request validator class. + webhook_response_validator_cls: Webhook response validator class. + request_unmarshaller_cls: Request unmarshaller class. + response_unmarshaller_cls: Response unmarshaller class. + webhook_request_unmarshaller_cls: Webhook request unmarshaller class. + webhook_response_unmarshaller_cls: Webhook response unmarshaller class. + """ + + spec_validator_cls: Union[SpecValidatorType, Unset] = _UNSET + spec_base_uri: str = "" + + request_validator_cls: Union[RequestValidatorType, Unset] = _UNSET + response_validator_cls: Union[ResponseValidatorType, Unset] = _UNSET + webhook_request_validator_cls: Union[ + WebhookRequestValidatorType, Unset + ] = _UNSET + webhook_response_validator_cls: Union[ + WebhookResponseValidatorType, Unset + ] = _UNSET + request_unmarshaller_cls: Union[RequestUnmarshallerType, Unset] = _UNSET + response_unmarshaller_cls: Union[ResponseUnmarshallerType, Unset] = _UNSET + webhook_request_unmarshaller_cls: Union[ + WebhookRequestUnmarshallerType, Unset + ] = _UNSET + webhook_response_unmarshaller_cls: Union[ + WebhookResponseUnmarshallerType, Unset + ] = _UNSET diff --git a/openapi_core/contrib/aiohttp/__init__.py b/openapi_core/contrib/aiohttp/__init__.py new file mode 100644 index 00000000..ac32f630 --- /dev/null +++ b/openapi_core/contrib/aiohttp/__init__.py @@ -0,0 +1,7 @@ +from openapi_core.contrib.aiohttp.requests import AIOHTTPOpenAPIWebRequest +from openapi_core.contrib.aiohttp.responses import AIOHTTPOpenAPIWebResponse + +__all__ = [ + "AIOHTTPOpenAPIWebRequest", + "AIOHTTPOpenAPIWebResponse", +] diff --git a/openapi_core/contrib/aiohttp/requests.py b/openapi_core/contrib/aiohttp/requests.py new file mode 100644 index 00000000..eac7965e --- /dev/null +++ b/openapi_core/contrib/aiohttp/requests.py @@ -0,0 +1,50 @@ +"""OpenAPI core contrib aiohttp requests module""" + +from __future__ import annotations + +from aiohttp import web + +from openapi_core.datatypes import RequestParameters + + +class Empty: ... + + +_empty = Empty() + + +class AIOHTTPOpenAPIWebRequest: + __slots__ = ("request", "parameters", "_get_body", "_body") + + def __init__(self, request: web.Request, *, body: bytes | None): + if not isinstance(request, web.Request): + raise TypeError( + f"'request' argument is not type of {web.Request.__qualname__!r}" + ) + self.request = request + self.parameters = RequestParameters( + query=self.request.query, + header=self.request.headers, + cookie=self.request.cookies, + ) + self._body = body + + @property + def host_url(self) -> str: + return f"{self.request.url.scheme}://{self.request.url.host}" + + @property + def path(self) -> str: + return self.request.url.path + + @property + def method(self) -> str: + return self.request.method.lower() + + @property + def body(self) -> bytes | None: + return self._body + + @property + def content_type(self) -> str: + return self.request.content_type diff --git a/openapi_core/contrib/aiohttp/responses.py b/openapi_core/contrib/aiohttp/responses.py new file mode 100644 index 00000000..ed337968 --- /dev/null +++ b/openapi_core/contrib/aiohttp/responses.py @@ -0,0 +1,34 @@ +"""OpenAPI core contrib aiohttp responses module""" + +import multidict +from aiohttp import web + + +class AIOHTTPOpenAPIWebResponse: + def __init__(self, response: web.Response): + if not isinstance(response, web.Response): + raise TypeError( + f"'response' argument is not type of {web.Response.__qualname__!r}" + ) + self.response = response + + @property + def data(self) -> bytes: + if self.response.body is None: + return b"" + if isinstance(self.response.body, bytes): + return self.response.body + assert isinstance(self.response.body, str) + return self.response.body.encode("utf-8") + + @property + def status_code(self) -> int: + return self.response.status + + @property + def content_type(self) -> str: + return self.response.content_type or "" + + @property + def headers(self) -> multidict.CIMultiDict[str]: + return self.response.headers diff --git a/openapi_core/contrib/django/__init__.py b/openapi_core/contrib/django/__init__.py index dbbd8f0b..ff65549b 100644 --- a/openapi_core/contrib/django/__init__.py +++ b/openapi_core/contrib/django/__init__.py @@ -1,11 +1,9 @@ -from openapi_core.contrib.django.requests import DjangoOpenAPIRequestFactory -from openapi_core.contrib.django.responses import DjangoOpenAPIResponseFactory +"""OpenAPI core contrib django module""" -# backward compatibility -DjangoOpenAPIRequest = DjangoOpenAPIRequestFactory.create -DjangoOpenAPIResponse = DjangoOpenAPIResponseFactory.create +from openapi_core.contrib.django.requests import DjangoOpenAPIRequest +from openapi_core.contrib.django.responses import DjangoOpenAPIResponse __all__ = [ - 'DjangoOpenAPIRequestFactory', 'DjangoOpenAPIResponseFactory', - 'DjangoOpenAPIRequest', 'DjangoOpenAPIResponse', + "DjangoOpenAPIRequest", + "DjangoOpenAPIResponse", ] diff --git a/openapi_core/contrib/django/decorators.py b/openapi_core/contrib/django/decorators.py new file mode 100644 index 00000000..f6be3cbf --- /dev/null +++ b/openapi_core/contrib/django/decorators.py @@ -0,0 +1,102 @@ +"""OpenAPI core contrib django decorators module""" + +from typing import Any +from typing import Callable +from typing import Optional +from typing import Type + +from django.conf import settings +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from jsonschema_path import SchemaPath + +from openapi_core import OpenAPI +from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler +from openapi_core.contrib.django.handlers import ( + DjangoOpenAPIValidRequestHandler, +) +from openapi_core.contrib.django.integrations import DjangoIntegration +from openapi_core.contrib.django.providers import get_default_openapi_instance +from openapi_core.contrib.django.requests import DjangoOpenAPIRequest +from openapi_core.contrib.django.responses import DjangoOpenAPIResponse + + +class DjangoOpenAPIViewDecorator(DjangoIntegration): + valid_request_handler_cls = DjangoOpenAPIValidRequestHandler + errors_handler_cls: Type[DjangoOpenAPIErrorsHandler] = ( + DjangoOpenAPIErrorsHandler + ) + + def __init__( + self, + openapi: Optional[OpenAPI] = None, + request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest, + response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse, + errors_handler_cls: Type[ + DjangoOpenAPIErrorsHandler + ] = DjangoOpenAPIErrorsHandler, + ): + if openapi is None: + openapi = get_default_openapi_instance() + + super().__init__(openapi) + + # If OPENAPI_RESPONSE_CLS is defined in settings.py (for custom response classes), + # set the response_cls accordingly. + if hasattr(settings, "OPENAPI_RESPONSE_CLS"): + response_cls = settings.OPENAPI_RESPONSE_CLS + + self.request_cls = request_cls + self.response_cls = response_cls + + def __call__(self, view_func: Callable[..., Any]) -> Callable[..., Any]: + """ + Thanks to this method, the class acts as a decorator. + Example usage: + + @DjangoOpenAPIViewDecorator() + def my_view(request): ... + + """ + + def _wrapped_view( + request: HttpRequest, *args: Any, **kwargs: Any + ) -> HttpResponse: + # get_response is the function that we treats + # as the "next step" in the chain (i.e., our original view). + def get_response(r: HttpRequest) -> HttpResponse: + return view_func(r, *args, **kwargs) + + # Create a handler that will validate the request. + valid_request_handler = self.valid_request_handler_cls( + request, get_response + ) + + # Validate the request (before running the view). + errors_handler = self.errors_handler_cls() + response = self.handle_request( + request, valid_request_handler, errors_handler + ) + + # Validate the response (after the view) if should_validate_response() returns True. + return self.handle_response(request, response, errors_handler) + + return _wrapped_view + + @classmethod + def from_spec( + cls, + spec: SchemaPath, + request_cls: Type[DjangoOpenAPIRequest] = DjangoOpenAPIRequest, + response_cls: Type[DjangoOpenAPIResponse] = DjangoOpenAPIResponse, + errors_handler_cls: Type[ + DjangoOpenAPIErrorsHandler + ] = DjangoOpenAPIErrorsHandler, + ) -> "DjangoOpenAPIViewDecorator": + openapi = OpenAPI(spec) + return cls( + openapi, + request_cls=request_cls, + response_cls=response_cls, + errors_handler_cls=errors_handler_cls, + ) diff --git a/openapi_core/contrib/django/handlers.py b/openapi_core/contrib/django/handlers.py new file mode 100644 index 00000000..a3618ab8 --- /dev/null +++ b/openapi_core/contrib/django/handlers.py @@ -0,0 +1,65 @@ +"""OpenAPI core contrib django handlers module""" + +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import Type + +from django.http import JsonResponse +from django.http.request import HttpRequest +from django.http.response import HttpResponse + +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult + + +class DjangoOpenAPIErrorsHandler: + OPENAPI_ERROR_STATUS: Dict[Type[BaseException], int] = { + ServerNotFound: 400, + SecurityNotFound: 403, + OperationNotFound: 405, + PathNotFound: 404, + MediaTypeNotFound: 415, + } + + def __call__( + self, + errors: Iterable[Exception], + ) -> JsonResponse: + data_errors = [self.format_openapi_error(err) for err in errors] + data = { + "errors": data_errors, + } + data_error_max = max(data_errors, key=self.get_error_status) + return JsonResponse(data, status=data_error_max["status"]) + + @classmethod + def format_openapi_error(cls, error: BaseException) -> Dict[str, Any]: + if error.__cause__ is not None: + error = error.__cause__ + return { + "title": str(error), + "status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), + "type": str(type(error)), + } + + @classmethod + def get_error_status(cls, error: Dict[str, Any]) -> str: + return str(error["status"]) + + +class DjangoOpenAPIValidRequestHandler: + def __init__(self, req: HttpRequest, view: Callable[[Any], HttpResponse]): + self.req = req + self.view = view + + def __call__( + self, request_unmarshal_result: RequestUnmarshalResult + ) -> HttpResponse: + self.req.openapi = request_unmarshal_result + return self.view(self.req) diff --git a/openapi_core/contrib/django/integrations.py b/openapi_core/contrib/django/integrations.py new file mode 100644 index 00000000..520aa7a6 --- /dev/null +++ b/openapi_core/contrib/django/integrations.py @@ -0,0 +1,36 @@ +from django.http.request import HttpRequest +from django.http.response import HttpResponse + +from openapi_core.contrib.django.requests import DjangoOpenAPIRequest +from openapi_core.contrib.django.responses import DjangoOpenAPIResponse +from openapi_core.unmarshalling.processors import UnmarshallingProcessor +from openapi_core.unmarshalling.typing import ErrorsHandlerCallable + + +class DjangoIntegration(UnmarshallingProcessor[HttpRequest, HttpResponse]): + request_cls = DjangoOpenAPIRequest + response_cls = DjangoOpenAPIResponse + + def get_openapi_request( + self, request: HttpRequest + ) -> DjangoOpenAPIRequest: + return self.request_cls(request) + + def get_openapi_response( + self, response: HttpResponse + ) -> DjangoOpenAPIResponse: + assert self.response_cls is not None + return self.response_cls(response) + + def should_validate_response(self) -> bool: + return self.response_cls is not None + + def handle_response( + self, + request: HttpRequest, + response: HttpResponse, + errors_handler: ErrorsHandlerCallable[HttpResponse], + ) -> HttpResponse: + if not self.should_validate_response(): + return response + return super().handle_response(request, response, errors_handler) diff --git a/openapi_core/contrib/django/middlewares.py b/openapi_core/contrib/django/middlewares.py new file mode 100644 index 00000000..34ffe273 --- /dev/null +++ b/openapi_core/contrib/django/middlewares.py @@ -0,0 +1,39 @@ +"""OpenAPI core contrib django middlewares module""" + +from typing import Callable + +from django.conf import settings +from django.http.request import HttpRequest +from django.http.response import HttpResponse + +from openapi_core.contrib.django.handlers import DjangoOpenAPIErrorsHandler +from openapi_core.contrib.django.handlers import ( + DjangoOpenAPIValidRequestHandler, +) +from openapi_core.contrib.django.integrations import DjangoIntegration +from openapi_core.contrib.django.providers import get_default_openapi_instance + + +class DjangoOpenAPIMiddleware(DjangoIntegration): + valid_request_handler_cls = DjangoOpenAPIValidRequestHandler + errors_handler = DjangoOpenAPIErrorsHandler() + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + if hasattr(settings, "OPENAPI_RESPONSE_CLS"): + self.response_cls = settings.OPENAPI_RESPONSE_CLS + + openapi = get_default_openapi_instance() + + super().__init__(openapi) + + def __call__(self, request: HttpRequest) -> HttpResponse: + valid_request_handler = self.valid_request_handler_cls( + request, self.get_response + ) + response = self.handle_request( + request, valid_request_handler, self.errors_handler + ) + + return self.handle_response(request, response, self.errors_handler) diff --git a/openapi_core/contrib/django/providers.py b/openapi_core/contrib/django/providers.py new file mode 100644 index 00000000..cb4f2a73 --- /dev/null +++ b/openapi_core/contrib/django/providers.py @@ -0,0 +1,31 @@ +"""OpenAPI core contrib django providers module""" + +import warnings +from typing import cast + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from openapi_core import OpenAPI + + +def get_default_openapi_instance() -> OpenAPI: + """ + Retrieves or initializes the OpenAPI instance based on Django settings + (either OPENAPI or OPENAPI_SPEC). + This function ensures the spec is only loaded once. + """ + if hasattr(settings, "OPENAPI"): + # Recommended (newer) approach + return cast(OpenAPI, settings.OPENAPI) + elif hasattr(settings, "OPENAPI_SPEC"): + # Backward compatibility + warnings.warn( + "OPENAPI_SPEC is deprecated. Use OPENAPI in your settings instead.", + DeprecationWarning, + ) + return OpenAPI(settings.OPENAPI_SPEC) + else: + raise ImproperlyConfigured( + "Neither OPENAPI nor OPENAPI_SPEC is defined in Django settings." + ) diff --git a/openapi_core/contrib/django/requests.py b/openapi_core/contrib/django/requests.py index 5e4e3a9a..10fb821d 100644 --- a/openapi_core/contrib/django/requests.py +++ b/openapi_core/contrib/django/requests.py @@ -1,53 +1,88 @@ """OpenAPI core contrib django requests module""" + import re +from typing import Optional -from six.moves.urllib.parse import urljoin +from django.http.request import HttpRequest +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict -from openapi_core.validation.request.datatypes import ( - RequestParameters, OpenAPIRequest, -) +from openapi_core.datatypes import RequestParameters -# https://docs.djangoproject.com/en/2.2/topics/http/urls/ +# https://docs.djangoproject.com/en/stable/topics/http/urls/ # # Currently unsupported are : -# - nested arguments, e.g.: ^comments/(?:page-(?P\d+)/)?$ -# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$ -# - multiple named parameters between a single pair of slashes +# - nested arguments, e.g.: ^comments/(?:page-(?P\d+)/)?$ +# - unnamed regex groups, e.g.: ^articles/([0-9]{4})/$ +# - multiple named parameters between a single pair of slashes # e.g.: -/edit/ # -# The regex matches everything, except a "/" until "<". Than only the name +# The regex matches everything, except a "/" until "<". Then only the name # is exported, after which it matches ">" and everything until a "/". -PATH_PARAMETER_PATTERN = r'(?:[^\/]*?)<(?:(?:.*?:))*?(\w+)>(?:[^\/]*)' - +# A check is made to ensure that "/" is not in an excluded character set such +# as may be found with Django REST Framwork's default value pattern, "[^/.]+". +PATH_PARAMETER_PATTERN = ( + r"(?:[^/]*?)<(?:(?:.*?:))*?(\w+)>(?:(?:[^/]*?\[\^[^/]*/)?[^/]*)" +) -class DjangoOpenAPIRequestFactory(object): +class DjangoOpenAPIRequest: path_regex = re.compile(PATH_PARAMETER_PATTERN) - @classmethod - def create(cls, request): - method = request.method.lower() + def __init__(self, request: HttpRequest): + if not isinstance(request, HttpRequest): + raise TypeError(f"'request' argument is not type of {HttpRequest}") + self.request = request - if request.resolver_match is None: - path_pattern = request.path - else: - route = cls.path_regex.sub( - r'{\1}', request.resolver_match.route) - path_pattern = '/' + route - - path = request.resolver_match and request.resolver_match.kwargs or {} - parameters = RequestParameters( - path=path, - query=request.GET, - header=request.headers.items(), - cookie=request.COOKIES, + path = ( + self.request.resolver_match + and self.request.resolver_match.kwargs + or {} ) - full_url_pattern = urljoin( - request._current_scheme_host, path_pattern) - return OpenAPIRequest( - full_url_pattern=full_url_pattern, - method=method, - parameters=parameters, - body=request.body, - mimetype=request.content_type, + self.parameters = RequestParameters( + path=path, + query=ImmutableMultiDict(self.request.GET), + header=Headers(self.request.headers.items()), + cookie=ImmutableMultiDict(dict(self.request.COOKIES)), ) + + @property + def host_url(self) -> str: + assert isinstance(self.request._current_scheme_host, str) + return self.request._current_scheme_host + + @property + def path(self) -> str: + assert isinstance(self.request.path, str) + return self.request.path + + @property + def path_pattern(self) -> Optional[str]: + if self.request.resolver_match is None: + return None + + route = self.path_regex.sub(r"{\1}", self.request.resolver_match.route) + # Delete start and end marker to allow concatenation. + if route[:1] == "^": + route = route[1:] + if route[-1:] == "$": + route = route[:-1] + return "/" + route + + @property + def method(self) -> str: + if self.request.method is None: + return "" + assert isinstance(self.request.method, str) + return self.request.method.lower() + + @property + def body(self) -> bytes: + assert isinstance(self.request.body, bytes) + return self.request.body + + @property + def content_type(self) -> str: + content_type = self.request.META.get("CONTENT_TYPE", "") + assert isinstance(content_type, str) + return content_type diff --git a/openapi_core/contrib/django/responses.py b/openapi_core/contrib/django/responses.py index efbe69d3..a1e245a4 100644 --- a/openapi_core/contrib/django/responses.py +++ b/openapi_core/contrib/django/responses.py @@ -1,14 +1,41 @@ """OpenAPI core contrib django responses module""" -from openapi_core.validation.response.datatypes import OpenAPIResponse +from itertools import tee -class DjangoOpenAPIResponseFactory(object): +from django.http.response import HttpResponse +from django.http.response import StreamingHttpResponse +from werkzeug.datastructures import Headers - @classmethod - def create(cls, response): - mimetype = response["Content-Type"] - return OpenAPIResponse( - data=response.content, - status_code=response.status_code, - mimetype=mimetype, - ) + +class DjangoOpenAPIResponse: + def __init__(self, response: HttpResponse): + if not isinstance(response, (HttpResponse, StreamingHttpResponse)): + raise TypeError( + f"'response' argument is not type of {HttpResponse} or {StreamingHttpResponse}" + ) + self.response = response + + @property + def data(self) -> bytes: + if isinstance(self.response, StreamingHttpResponse): + resp_iter1, resp_iter2 = tee(self.response._iterator) + self.response.streaming_content = resp_iter1 + content = b"".join(map(self.response.make_bytes, resp_iter2)) + return content + assert isinstance(self.response.content, bytes) + return self.response.content + + @property + def status_code(self) -> int: + assert isinstance(self.response.status_code, int) + return self.response.status_code + + @property + def headers(self) -> Headers: + return Headers(self.response.headers.items()) + + @property + def content_type(self) -> str: + content_type = self.response.get("Content-Type", "") + assert isinstance(content_type, str) + return content_type diff --git a/openapi_core/contrib/falcon/__init__.py b/openapi_core/contrib/falcon/__init__.py index 3183150f..67c28a13 100644 --- a/openapi_core/contrib/falcon/__init__.py +++ b/openapi_core/contrib/falcon/__init__.py @@ -1,5 +1,7 @@ -from openapi_core.contrib.falcon.requests import FalconOpenAPIRequestFactory -from openapi_core.contrib.falcon.responses import FalconOpenAPIResponseFactory +from openapi_core.contrib.falcon.requests import FalconOpenAPIRequest +from openapi_core.contrib.falcon.responses import FalconOpenAPIResponse - -__all__ = ["FalconOpenAPIRequestFactory", "FalconOpenAPIResponseFactory"] +__all__ = [ + "FalconOpenAPIRequest", + "FalconOpenAPIResponse", +] diff --git a/openapi_core/contrib/falcon/handlers.py b/openapi_core/contrib/falcon/handlers.py index 4f535608..d390d46a 100644 --- a/openapi_core/contrib/falcon/handlers.py +++ b/openapi_core/contrib/falcon/handlers.py @@ -1,52 +1,76 @@ """OpenAPI core contrib falcon handlers module""" + from json import dumps +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Type +from falcon import status_codes from falcon.constants import MEDIA_JSON -from falcon.status_codes import ( - HTTP_400, HTTP_404, HTTP_405, HTTP_415, -) -from openapi_core.schema.media_types.exceptions import InvalidContentType -from openapi_core.templating.paths.exceptions import ( - ServerNotFound, OperationNotFound, PathNotFound, -) +from falcon.request import Request +from falcon.response import Response +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult -class FalconOpenAPIErrorsHandler(object): - OPENAPI_ERROR_STATUS = { +class FalconOpenAPIErrorsHandler: + OPENAPI_ERROR_STATUS: Dict[Type[BaseException], int] = { ServerNotFound: 400, + SecurityNotFound: 403, OperationNotFound: 405, PathNotFound: 404, - InvalidContentType: 415, + MediaTypeNotFound: 415, } - FALCON_STATUS_CODES = { - 400: HTTP_400, - 404: HTTP_404, - 405: HTTP_405, - 415: HTTP_415, - } + def __init__(self, req: Request, resp: Response): + self.req = req + self.resp = resp - @classmethod - def handle(cls, req, resp, errors): - data_errors = [ - cls.format_openapi_error(err) - for err in errors - ] + def __call__(self, errors: Iterable[Exception]) -> Response: + data_errors = [self.format_openapi_error(err) for err in errors] data = { - 'errors': data_errors, + "errors": data_errors, } - data_error_max = max(data_errors, key=lambda x: x['status']) - resp.content_type = MEDIA_JSON - resp.status = cls.FALCON_STATUS_CODES.get( - data_error_max['status'], HTTP_400) - resp.body = dumps(data) - resp.complete = True + data_str = dumps(data) + data_error_max = max(data_errors, key=self.get_error_status) + self.resp.content_type = MEDIA_JSON + self.resp.status = getattr( + status_codes, + f"HTTP_{data_error_max['status']}", + status_codes.HTTP_400, + ) + self.resp.text = data_str + self.resp.complete = True + return self.resp @classmethod - def format_openapi_error(cls, error): + def format_openapi_error(cls, error: BaseException) -> Dict[str, Any]: + if error.__cause__ is not None: + error = error.__cause__ return { - 'title': str(error), - 'status': cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), - 'class': str(type(error)), + "title": str(error), + "status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), + "type": str(type(error)), } + + @classmethod + def get_error_status(cls, error: Dict[str, Any]) -> int: + return int(error["status"]) + + +class FalconOpenAPIValidRequestHandler: + def __init__(self, req: Request, resp: Response): + self.req = req + self.resp = resp + + def __call__( + self, request_unmarshal_result: RequestUnmarshalResult + ) -> Response: + self.req.context.openapi = request_unmarshal_result + return self.resp diff --git a/openapi_core/contrib/falcon/integrations.py b/openapi_core/contrib/falcon/integrations.py new file mode 100644 index 00000000..8c3fa544 --- /dev/null +++ b/openapi_core/contrib/falcon/integrations.py @@ -0,0 +1,34 @@ +from falcon.request import Request +from falcon.response import Response + +from openapi_core.contrib.falcon.requests import FalconOpenAPIRequest +from openapi_core.contrib.falcon.responses import FalconOpenAPIResponse +from openapi_core.unmarshalling.processors import UnmarshallingProcessor +from openapi_core.unmarshalling.typing import ErrorsHandlerCallable + + +class FalconIntegration(UnmarshallingProcessor[Request, Response]): + request_cls = FalconOpenAPIRequest + response_cls = FalconOpenAPIResponse + + def get_openapi_request(self, request: Request) -> FalconOpenAPIRequest: + return self.request_cls(request) + + def get_openapi_response( + self, response: Response + ) -> FalconOpenAPIResponse: + assert self.response_cls is not None + return self.response_cls(response) + + def should_validate_response(self) -> bool: + return self.response_cls is not None + + def handle_response( + self, + request: Request, + response: Response, + errors_handler: ErrorsHandlerCallable[Response], + ) -> Response: + if not self.should_validate_response(): + return response + return super().handle_response(request, response, errors_handler) diff --git a/openapi_core/contrib/falcon/middlewares.py b/openapi_core/contrib/falcon/middlewares.py index a7819cf9..13e4c5e8 100644 --- a/openapi_core/contrib/falcon/middlewares.py +++ b/openapi_core/contrib/falcon/middlewares.py @@ -1,73 +1,88 @@ """OpenAPI core contrib falcon middlewares module""" +from typing import Any +from typing import Type +from typing import Union + +from falcon.request import Request +from falcon.response import Response +from jsonschema._utils import Unset +from jsonschema.validators import _UNSET +from jsonschema_path import SchemaPath + +from openapi_core import Config +from openapi_core import OpenAPI from openapi_core.contrib.falcon.handlers import FalconOpenAPIErrorsHandler -from openapi_core.contrib.falcon.requests import FalconOpenAPIRequestFactory -from openapi_core.contrib.falcon.responses import FalconOpenAPIResponseFactory -from openapi_core.validation.processors import OpenAPIProcessor -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.contrib.falcon.handlers import ( + FalconOpenAPIValidRequestHandler, +) +from openapi_core.contrib.falcon.integrations import FalconIntegration +from openapi_core.contrib.falcon.requests import FalconOpenAPIRequest +from openapi_core.contrib.falcon.responses import FalconOpenAPIResponse +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType -class FalconOpenAPIMiddleware(OpenAPIProcessor): +class FalconOpenAPIMiddleware(FalconIntegration): + valid_request_handler_cls = FalconOpenAPIValidRequestHandler + errors_handler_cls: Type[FalconOpenAPIErrorsHandler] = ( + FalconOpenAPIErrorsHandler + ) def __init__( - self, - request_validator, - response_validator, - request_factory, - response_factory, - openapi_errors_handler, + self, + openapi: OpenAPI, + request_cls: Type[FalconOpenAPIRequest] = FalconOpenAPIRequest, + response_cls: Type[FalconOpenAPIResponse] = FalconOpenAPIResponse, + errors_handler_cls: Type[ + FalconOpenAPIErrorsHandler + ] = FalconOpenAPIErrorsHandler, + **unmarshaller_kwargs: Any, ): - super(FalconOpenAPIMiddleware, self).__init__( - request_validator, response_validator) - self.request_factory = request_factory - self.response_factory = response_factory - self.openapi_errors_handler = openapi_errors_handler - - def process_request(self, req, resp): - openapi_req = self._get_openapi_request(req) - req_result = super(FalconOpenAPIMiddleware, self).process_request( - openapi_req) - if req_result.errors: - return self._handle_request_errors(req, resp, req_result) - req.openapi = req_result - - def process_response(self, req, resp, resource, req_succeeded): - openapi_req = self._get_openapi_request(req) - openapi_resp = self._get_openapi_response(resp) - resp_result = super(FalconOpenAPIMiddleware, self).process_response( - openapi_req, openapi_resp) - if resp_result.errors: - return self._handle_response_errors(req, resp, resp_result) - - def _handle_request_errors(self, req, resp, request_result): - return self.openapi_errors_handler.handle( - req, resp, request_result.errors) - - def _handle_response_errors(self, req, resp, response_result): - return self.openapi_errors_handler.handle( - req, resp, response_result.errors) - - def _get_openapi_request(self, request): - return self.request_factory.create(request) - - def _get_openapi_response(self, response): - return self.response_factory.create(response) + super().__init__(openapi) + self.request_cls = request_cls or self.request_cls + self.response_cls = response_cls or self.response_cls + self.errors_handler_cls = errors_handler_cls or self.errors_handler_cls @classmethod def from_spec( - cls, - spec, - request_factory=FalconOpenAPIRequestFactory, - response_factory=FalconOpenAPIResponseFactory, - openapi_errors_handler=FalconOpenAPIErrorsHandler, - ): - request_validator = RequestValidator(spec) - response_validator = ResponseValidator(spec) + cls, + spec: SchemaPath, + request_unmarshaller_cls: Union[ + RequestUnmarshallerType, Unset + ] = _UNSET, + response_unmarshaller_cls: Union[ + ResponseUnmarshallerType, Unset + ] = _UNSET, + request_cls: Type[FalconOpenAPIRequest] = FalconOpenAPIRequest, + response_cls: Type[FalconOpenAPIResponse] = FalconOpenAPIResponse, + errors_handler_cls: Type[ + FalconOpenAPIErrorsHandler + ] = FalconOpenAPIErrorsHandler, + **unmarshaller_kwargs: Any, + ) -> "FalconOpenAPIMiddleware": + config = Config( + request_unmarshaller_cls=request_unmarshaller_cls, + response_unmarshaller_cls=response_unmarshaller_cls, + ) + openapi = OpenAPI(spec, config=config) return cls( - request_validator=request_validator, - response_validator=response_validator, - request_factory=request_factory, - response_factory=response_factory, - openapi_errors_handler=openapi_errors_handler, + openapi, + request_unmarshaller_cls=request_unmarshaller_cls, + response_unmarshaller_cls=response_unmarshaller_cls, + request_cls=request_cls, + response_cls=response_cls, + errors_handler_cls=errors_handler_cls, + **unmarshaller_kwargs, ) + + def process_request(self, req: Request, resp: Response) -> None: + valid_handler = self.valid_request_handler_cls(req, resp) + errors_handler = self.errors_handler_cls(req, resp) + self.handle_request(req, valid_handler, errors_handler) + + def process_response( + self, req: Request, resp: Response, resource: Any, req_succeeded: bool + ) -> None: + errors_handler = self.errors_handler_cls(req, resp) + self.handle_response(req, resp, errors_handler) diff --git a/openapi_core/contrib/falcon/requests.py b/openapi_core/contrib/falcon/requests.py index e48b4fa7..586bd82d 100644 --- a/openapi_core/contrib/falcon/requests.py +++ b/openapi_core/contrib/falcon/requests.py @@ -1,46 +1,88 @@ """OpenAPI core contrib falcon responses module""" + +import warnings from json import dumps +from typing import Any +from typing import Dict +from typing import Optional +from falcon.request import Request +from falcon.request import RequestOptions +from werkzeug.datastructures import Headers from werkzeug.datastructures import ImmutableMultiDict -from openapi_core.validation.request.datatypes import ( - OpenAPIRequest, RequestParameters, -) +from openapi_core.contrib.falcon.util import unpack_params +from openapi_core.datatypes import RequestParameters + + +class FalconOpenAPIRequest: + def __init__( + self, + request: Request, + default_when_empty: Optional[Dict[Any, Any]] = None, + ): + if not isinstance(request, Request): + raise TypeError(f"'request' argument is not type of {Request}") + self.request = request + if default_when_empty is None: + default_when_empty = {} + self.default_when_empty = default_when_empty + + # Path gets deduced by path finder against spec + self.parameters = RequestParameters( + query=ImmutableMultiDict(unpack_params(self.request.params)), + header=Headers(self.request.headers), + cookie=self.request.cookies, + ) + @property + def host_url(self) -> str: + assert isinstance(self.request.prefix, str) + return self.request.prefix -class FalconOpenAPIRequestFactory: + @property + def path(self) -> str: + assert isinstance(self.request.path, str) + return self.request.path - @classmethod - def create(cls, request): - """ - Create OpenAPIRequest from falcon Request and route params. - """ - method = request.method.lower() + @property + def method(self) -> str: + assert isinstance(self.request.method, str) + return self.request.method.lower() - # gets deduced by path finder against spec - path = {} + @property + def body(self) -> Optional[bytes]: + # Falcon doesn't store raw request stream. + # That's why we need to revert deserialized data # Support falcon-jsonify. - body = ( - dumps(request.json) if getattr(request, "json", None) - else dumps(request.media) - ) - mimetype = request.options.default_media_type - if request.content_type: - mimetype = request.content_type.partition(";")[0] - - query = ImmutableMultiDict(request.params.items()) - parameters = RequestParameters( - query=query, - header=request.headers, - cookie=request.cookies, - path=path, + if hasattr(self.request, "json"): + return dumps(self.request.json).encode("utf-8") + + media = self.request.get_media( + default_when_empty=self.default_when_empty, ) - url_pattern = request.prefix + request.path - return OpenAPIRequest( - full_url_pattern=url_pattern, - method=method, - parameters=parameters, - body=body, - mimetype=mimetype, + handler, _, _ = self.request.options.media_handlers._resolve( + self.request.content_type, self.request.options.default_media_type ) + try: + body = handler.serialize(media, content_type=self.content_type) + # multipart form serialization is not supported + except NotImplementedError: + warnings.warn( + f"body serialization for {self.request.content_type} not supported" + ) + return None + else: + assert isinstance(body, bytes) + return body + + @property + def content_type(self) -> str: + if self.request.content_type: + assert isinstance(self.request.content_type, str) + return self.request.content_type + + assert isinstance(self.request.options, RequestOptions) + assert isinstance(self.request.options.default_media_type, str) + return self.request.options.default_media_type diff --git a/openapi_core/contrib/falcon/responses.py b/openapi_core/contrib/falcon/responses.py index 9cca6597..22bdb81a 100644 --- a/openapi_core/contrib/falcon/responses.py +++ b/openapi_core/contrib/falcon/responses.py @@ -1,20 +1,50 @@ """OpenAPI core contrib falcon responses module""" -from openapi_core.validation.response.datatypes import OpenAPIResponse +from io import BytesIO +from itertools import tee +from typing import Iterable -class FalconOpenAPIResponseFactory(object): - @classmethod - def create(cls, response): - status_code = int(response.status[:3]) +from falcon.response import Response +from werkzeug.datastructures import Headers - mimetype = '' - if response.content_type: - mimetype = response.content_type.partition(";")[0] + +class FalconOpenAPIResponse: + def __init__(self, response: Response): + if not isinstance(response, Response): + raise TypeError(f"'response' argument is not type of {Response}") + self.response = response + + @property + def data(self) -> bytes: + if self.response.text is None: + if self.response.stream is None: + return b"" + if isinstance(self.response.stream, Iterable): + resp_iter1, resp_iter2 = tee(self.response.stream) + self.response.stream = resp_iter1 + content = b"".join(resp_iter2) + return content + # checks ReadableIO protocol + if hasattr(self.response.stream, "read"): + data = self.response.stream.read() + self.response.stream = BytesIO(data) + return data + assert isinstance(self.response.text, str) + return self.response.text.encode("utf-8") + + @property + def status_code(self) -> int: + return self.response.status_code + + @property + def content_type(self) -> str: + content_type = "" + if self.response.content_type: + content_type = self.response.content_type else: - mimetype = response.options.default_media_type + content_type = self.response.options.default_media_type + return content_type - return OpenAPIResponse( - data=response.body, - status_code=status_code, - mimetype=mimetype, - ) + @property + def headers(self) -> Headers: + return Headers(self.response.headers) diff --git a/openapi_core/contrib/falcon/util.py b/openapi_core/contrib/falcon/util.py new file mode 100644 index 00000000..b1360bcd --- /dev/null +++ b/openapi_core/contrib/falcon/util.py @@ -0,0 +1,15 @@ +from typing import Any +from typing import Generator +from typing import Mapping +from typing import Tuple + + +def unpack_params( + params: Mapping[str, Any] +) -> Generator[Tuple[str, Any], None, None]: + for k, v in params.items(): + if isinstance(v, list): + for v2 in v: + yield (k, v2) + else: + yield (k, v) diff --git a/openapi_core/contrib/fastapi/__init__.py b/openapi_core/contrib/fastapi/__init__.py new file mode 100644 index 00000000..d658ddcf --- /dev/null +++ b/openapi_core/contrib/fastapi/__init__.py @@ -0,0 +1,9 @@ +from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware +from openapi_core.contrib.fastapi.requests import FastAPIOpenAPIRequest +from openapi_core.contrib.fastapi.responses import FastAPIOpenAPIResponse + +__all__ = [ + "FastAPIOpenAPIMiddleware", + "FastAPIOpenAPIRequest", + "FastAPIOpenAPIResponse", +] diff --git a/openapi_core/contrib/fastapi/middlewares.py b/openapi_core/contrib/fastapi/middlewares.py new file mode 100644 index 00000000..5aedf224 --- /dev/null +++ b/openapi_core/contrib/fastapi/middlewares.py @@ -0,0 +1,5 @@ +from openapi_core.contrib.starlette.middlewares import ( + StarletteOpenAPIMiddleware as FastAPIOpenAPIMiddleware, +) + +__all__ = ["FastAPIOpenAPIMiddleware"] diff --git a/openapi_core/contrib/fastapi/requests.py b/openapi_core/contrib/fastapi/requests.py new file mode 100644 index 00000000..c70d8c81 --- /dev/null +++ b/openapi_core/contrib/fastapi/requests.py @@ -0,0 +1,8 @@ +from fastapi import Request + +from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest + + +class FastAPIOpenAPIRequest(StarletteOpenAPIRequest): + def __init__(self, request: Request): + super().__init__(request) diff --git a/openapi_core/contrib/fastapi/responses.py b/openapi_core/contrib/fastapi/responses.py new file mode 100644 index 00000000..6ef7ea22 --- /dev/null +++ b/openapi_core/contrib/fastapi/responses.py @@ -0,0 +1,10 @@ +from typing import Optional + +from fastapi import Response + +from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse + + +class FastAPIOpenAPIResponse(StarletteOpenAPIResponse): + def __init__(self, response: Response, data: Optional[bytes] = None): + super().__init__(response, data=data) diff --git a/openapi_core/contrib/flask/__init__.py b/openapi_core/contrib/flask/__init__.py index 415b74cf..c7d0bf2b 100644 --- a/openapi_core/contrib/flask/__init__.py +++ b/openapi_core/contrib/flask/__init__.py @@ -1,11 +1,9 @@ -from openapi_core.contrib.flask.requests import FlaskOpenAPIRequestFactory -from openapi_core.contrib.flask.responses import FlaskOpenAPIResponseFactory - -# backward compatibility -FlaskOpenAPIRequest = FlaskOpenAPIRequestFactory.create -FlaskOpenAPIResponse = FlaskOpenAPIResponseFactory.create +from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator +from openapi_core.contrib.flask.requests import FlaskOpenAPIRequest +from openapi_core.contrib.flask.responses import FlaskOpenAPIResponse __all__ = [ - 'FlaskOpenAPIRequestFactory', 'FlaskOpenAPIResponseFactory', - 'FlaskOpenAPIRequest', 'FlaskOpenAPIResponse', + "FlaskOpenAPIViewDecorator", + "FlaskOpenAPIRequest", + "FlaskOpenAPIResponse", ] diff --git a/openapi_core/contrib/flask/decorators.py b/openapi_core/contrib/flask/decorators.py index 6c0d1b3a..4dc949e9 100644 --- a/openapi_core/contrib/flask/decorators.py +++ b/openapi_core/contrib/flask/decorators.py @@ -1,52 +1,80 @@ """OpenAPI core contrib flask decorators module""" + +from functools import wraps +from typing import Any +from typing import Callable +from typing import Type + +from flask.globals import request +from flask.wrappers import Request +from flask.wrappers import Response +from jsonschema_path import SchemaPath + +from openapi_core import OpenAPI from openapi_core.contrib.flask.handlers import FlaskOpenAPIErrorsHandler +from openapi_core.contrib.flask.handlers import FlaskOpenAPIValidRequestHandler +from openapi_core.contrib.flask.integrations import FlaskIntegration from openapi_core.contrib.flask.providers import FlaskRequestProvider -from openapi_core.contrib.flask.requests import FlaskOpenAPIRequestFactory -from openapi_core.contrib.flask.responses import FlaskOpenAPIResponseFactory -from openapi_core.validation.decorators import OpenAPIDecorator -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.contrib.flask.requests import FlaskOpenAPIRequest +from openapi_core.contrib.flask.responses import FlaskOpenAPIResponse -class FlaskOpenAPIViewDecorator(OpenAPIDecorator): +class FlaskOpenAPIViewDecorator(FlaskIntegration): + valid_request_handler_cls = FlaskOpenAPIValidRequestHandler + errors_handler_cls: Type[FlaskOpenAPIErrorsHandler] = ( + FlaskOpenAPIErrorsHandler + ) def __init__( - self, - request_validator, - response_validator, - request_factory=FlaskOpenAPIRequestFactory, - response_factory=FlaskOpenAPIResponseFactory, - request_provider=FlaskRequestProvider, - openapi_errors_handler=FlaskOpenAPIErrorsHandler, + self, + openapi: OpenAPI, + request_cls: Type[FlaskOpenAPIRequest] = FlaskOpenAPIRequest, + response_cls: Type[FlaskOpenAPIResponse] = FlaskOpenAPIResponse, + request_provider: Type[FlaskRequestProvider] = FlaskRequestProvider, + errors_handler_cls: Type[ + FlaskOpenAPIErrorsHandler + ] = FlaskOpenAPIErrorsHandler, ): - super(FlaskOpenAPIViewDecorator, self).__init__( - request_validator, response_validator, - request_factory, response_factory, - request_provider, openapi_errors_handler, - ) + super().__init__(openapi) + self.request_cls = request_cls + self.response_cls = response_cls + self.request_provider = request_provider + self.errors_handler_cls = errors_handler_cls - def _handle_request_view(self, request_result, view, *args, **kwargs): - request = self._get_request(*args, **kwargs) - request.openapi = request_result - return super(FlaskOpenAPIViewDecorator, self)._handle_request_view( - request_result, view, *args, **kwargs) + def __call__(self, view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def decorated(*args: Any, **kwargs: Any) -> Response: + request = self.get_request() + valid_request_handler = self.valid_request_handler_cls( + request, view, *args, **kwargs + ) + errors_handler = self.errors_handler_cls() + response = self.handle_request( + request, valid_request_handler, errors_handler + ) + return self.handle_response(request, response, errors_handler) + + return decorated + + def get_request(self) -> Request: + return request @classmethod def from_spec( - cls, - spec, - request_factory=FlaskOpenAPIRequestFactory, - response_factory=FlaskOpenAPIResponseFactory, - request_provider=FlaskRequestProvider, - openapi_errors_handler=FlaskOpenAPIErrorsHandler, - ): - request_validator = RequestValidator(spec) - response_validator = ResponseValidator(spec) + cls, + spec: SchemaPath, + request_cls: Type[FlaskOpenAPIRequest] = FlaskOpenAPIRequest, + response_cls: Type[FlaskOpenAPIResponse] = FlaskOpenAPIResponse, + request_provider: Type[FlaskRequestProvider] = FlaskRequestProvider, + errors_handler_cls: Type[ + FlaskOpenAPIErrorsHandler + ] = FlaskOpenAPIErrorsHandler, + ) -> "FlaskOpenAPIViewDecorator": + openapi = OpenAPI(spec) return cls( - request_validator=request_validator, - response_validator=response_validator, - request_factory=request_factory, - response_factory=response_factory, + openapi, + request_cls=request_cls, + response_cls=response_cls, request_provider=request_provider, - openapi_errors_handler=openapi_errors_handler, + errors_handler_cls=errors_handler_cls, ) diff --git a/openapi_core/contrib/flask/handlers.py b/openapi_core/contrib/flask/handlers.py index 29fecf6b..3a207112 100644 --- a/openapi_core/contrib/flask/handlers.py +++ b/openapi_core/contrib/flask/handlers.py @@ -1,43 +1,76 @@ """OpenAPI core contrib flask handlers module""" + +from typing import Any +from typing import Callable +from typing import Dict +from typing import Iterable +from typing import Type + from flask.globals import current_app +from flask.helpers import make_response from flask.json import dumps +from flask.wrappers import Request +from flask.wrappers import Response -from openapi_core.schema.media_types.exceptions import InvalidContentType -from openapi_core.templating.paths.exceptions import ( - ServerNotFound, OperationNotFound, PathNotFound, -) - +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult -class FlaskOpenAPIErrorsHandler(object): - OPENAPI_ERROR_STATUS = { +class FlaskOpenAPIErrorsHandler: + OPENAPI_ERROR_STATUS: Dict[Type[BaseException], int] = { ServerNotFound: 400, + SecurityNotFound: 403, OperationNotFound: 405, PathNotFound: 404, - InvalidContentType: 415, + MediaTypeNotFound: 415, } - @classmethod - def handle(cls, errors): - data_errors = [ - cls.format_openapi_error(err) - for err in errors - ] + def __call__(self, errors: Iterable[Exception]) -> Response: + data_errors = [self.format_openapi_error(err) for err in errors] data = { - 'errors': data_errors, + "errors": data_errors, } - data_error_max = max(data_errors, key=lambda x: x['status']) - status = data_error_max['status'] + data_error_max = max(data_errors, key=self.get_error_status) + status = data_error_max["status"] return current_app.response_class( - dumps(data), - status=status, - mimetype='application/json' + dumps(data), status=status, mimetype="application/json" ) @classmethod - def format_openapi_error(cls, error): + def format_openapi_error(cls, error: BaseException) -> Dict[str, Any]: + if error.__cause__ is not None: + error = error.__cause__ return { - 'title': str(error), - 'status': cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), - 'class': str(type(error)), + "title": str(error), + "status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), + "class": str(type(error)), } + + @classmethod + def get_error_status(cls, error: Dict[str, Any]) -> int: + return int(error["status"]) + + +class FlaskOpenAPIValidRequestHandler: + def __init__( + self, + req: Request, + view: Callable[[Any], Response], + *view_args: Any, + **view_kwargs: Any, + ): + self.req = req + self.view = view + self.view_args = view_args + self.view_kwargs = view_kwargs + + def __call__( + self, request_unmarshal_result: RequestUnmarshalResult + ) -> Response: + self.req.openapi = request_unmarshal_result # type: ignore + rv = self.view(*self.view_args, **self.view_kwargs) + return make_response(rv) diff --git a/openapi_core/contrib/flask/integrations.py b/openapi_core/contrib/flask/integrations.py new file mode 100644 index 00000000..49f7009e --- /dev/null +++ b/openapi_core/contrib/flask/integrations.py @@ -0,0 +1,32 @@ +from flask.wrappers import Request +from flask.wrappers import Response + +from openapi_core.contrib.flask.requests import FlaskOpenAPIRequest +from openapi_core.contrib.flask.responses import FlaskOpenAPIResponse +from openapi_core.unmarshalling.processors import UnmarshallingProcessor +from openapi_core.unmarshalling.typing import ErrorsHandlerCallable + + +class FlaskIntegration(UnmarshallingProcessor[Request, Response]): + request_cls = FlaskOpenAPIRequest + response_cls = FlaskOpenAPIResponse + + def get_openapi_request(self, request: Request) -> FlaskOpenAPIRequest: + return self.request_cls(request) + + def get_openapi_response(self, response: Response) -> FlaskOpenAPIResponse: + assert self.response_cls is not None + return self.response_cls(response) + + def should_validate_response(self) -> bool: + return self.response_cls is not None + + def handle_response( + self, + request: Request, + response: Response, + errors_handler: ErrorsHandlerCallable[Response], + ) -> Response: + if not self.should_validate_response(): + return response + return super().handle_response(request, response, errors_handler) diff --git a/openapi_core/contrib/flask/providers.py b/openapi_core/contrib/flask/providers.py index b7c61b5f..48f39825 100644 --- a/openapi_core/contrib/flask/providers.py +++ b/openapi_core/contrib/flask/providers.py @@ -1,9 +1,12 @@ """OpenAPI core contrib flask providers module""" -from flask.globals import request +from typing import Any + +from flask.globals import request +from flask.wrappers import Request -class FlaskRequestProvider(object): +class FlaskRequestProvider: @classmethod - def provide(self, *args, **kwargs): + def provide(self, *args: Any, **kwargs: Any) -> Request: return request diff --git a/openapi_core/contrib/flask/requests.py b/openapi_core/contrib/flask/requests.py index d9b5d262..9a9d5e5c 100644 --- a/openapi_core/contrib/flask/requests.py +++ b/openapi_core/contrib/flask/requests.py @@ -1,40 +1,30 @@ """OpenAPI core contrib flask requests module""" -import re -from six.moves.urllib.parse import urljoin +from flask.wrappers import Request +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict -from openapi_core.validation.request.datatypes import ( - RequestParameters, OpenAPIRequest, -) +from openapi_core.contrib.werkzeug.requests import WerkzeugOpenAPIRequest +from openapi_core.datatypes import RequestParameters -# http://flask.pocoo.org/docs/1.0/quickstart/#variable-rules -PATH_PARAMETER_PATTERN = r'<(?:(?:string|int|float|path|uuid):)?(\w+)>' +class FlaskOpenAPIRequest(WerkzeugOpenAPIRequest): + def __init__(self, request: Request): + if not isinstance(request, Request): + raise TypeError(f"'request' argument is not type of {Request}") + self.request: Request = request -class FlaskOpenAPIRequestFactory(object): - - path_regex = re.compile(PATH_PARAMETER_PATTERN) - - @classmethod - def create(cls, request): - method = request.method.lower() + self.parameters = RequestParameters( + path=self.request.view_args or {}, + query=ImmutableMultiDict(self.request.args), + header=Headers(self.request.headers), + cookie=self.request.cookies, + ) - if request.url_rule is None: - path_pattern = request.path - else: - path_pattern = cls.path_regex.sub(r'{\1}', request.url_rule.rule) + @property + def path_pattern(self) -> str: + if self.request.url_rule is None: + return self.path - parameters = RequestParameters( - path=request.view_args, - query=request.args, - header=request.headers, - cookie=request.cookies, - ) - full_url_pattern = urljoin(request.host_url, path_pattern) - return OpenAPIRequest( - full_url_pattern=full_url_pattern, - method=method, - parameters=parameters, - body=request.data, - mimetype=request.mimetype, - ) + path = self.get_path(self.request.url_rule.rule) + return self.path_regex.sub(r"{\1}", path) diff --git a/openapi_core/contrib/flask/responses.py b/openapi_core/contrib/flask/responses.py index 73e7605b..cff7ea15 100644 --- a/openapi_core/contrib/flask/responses.py +++ b/openapi_core/contrib/flask/responses.py @@ -1,13 +1,5 @@ -"""OpenAPI core contrib flask responses module""" -from openapi_core.validation.response.datatypes import OpenAPIResponse +from openapi_core.contrib.werkzeug.responses import ( + WerkzeugOpenAPIResponse as FlaskOpenAPIResponse, +) - -class FlaskOpenAPIResponseFactory(object): - - @classmethod - def create(cls, response): - return OpenAPIResponse( - data=response.data, - status_code=response._status_code, - mimetype=response.mimetype, - ) +__all__ = ["FlaskOpenAPIResponse"] diff --git a/openapi_core/contrib/flask/views.py b/openapi_core/contrib/flask/views.py index e1f84bf5..5b1d0da2 100644 --- a/openapi_core/contrib/flask/views.py +++ b/openapi_core/contrib/flask/views.py @@ -1,10 +1,12 @@ """OpenAPI core contrib flask views module""" + +from typing import Any + from flask.views import MethodView +from openapi_core import OpenAPI from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator from openapi_core.contrib.flask.handlers import FlaskOpenAPIErrorsHandler -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator class FlaskOpenAPIView(MethodView): @@ -12,16 +14,15 @@ class FlaskOpenAPIView(MethodView): openapi_errors_handler = FlaskOpenAPIErrorsHandler - def __init__(self, spec): - super(FlaskOpenAPIView, self).__init__() - self.request_validator = RequestValidator(spec) - self.response_validator = ResponseValidator(spec) + def __init__(self, openapi: OpenAPI): + super().__init__() - def dispatch_request(self, *args, **kwargs): - decorator = FlaskOpenAPIViewDecorator( - request_validator=self.request_validator, - response_validator=self.response_validator, - openapi_errors_handler=self.openapi_errors_handler, + self.decorator = FlaskOpenAPIViewDecorator( + openapi, + errors_handler_cls=self.openapi_errors_handler, ) - return decorator(super(FlaskOpenAPIView, self).dispatch_request)( - *args, **kwargs) + + def dispatch_request(self, *args: Any, **kwargs: Any) -> Any: + response = self.decorator(super().dispatch_request)(*args, **kwargs) + + return response diff --git a/openapi_core/contrib/requests/__init__.py b/openapi_core/contrib/requests/__init__.py index a95180a1..d0327d7d 100644 --- a/openapi_core/contrib/requests/__init__.py +++ b/openapi_core/contrib/requests/__init__.py @@ -1,15 +1,11 @@ +from openapi_core.contrib.requests.requests import RequestsOpenAPIRequest from openapi_core.contrib.requests.requests import ( - RequestsOpenAPIRequestFactory, + RequestsOpenAPIWebhookRequest, ) -from openapi_core.contrib.requests.responses import ( - RequestsOpenAPIResponseFactory, -) - -# backward compatibility -RequestsOpenAPIRequest = RequestsOpenAPIRequestFactory.create -RequestsOpenAPIResponse = RequestsOpenAPIResponseFactory.create +from openapi_core.contrib.requests.responses import RequestsOpenAPIResponse __all__ = [ - 'RequestsOpenAPIRequestFactory', 'RequestsOpenAPIResponseFactory', - 'RequestsOpenAPIRequest', 'RequestsOpenAPIResponse', + "RequestsOpenAPIRequest", + "RequestsOpenAPIResponse", + "RequestsOpenAPIWebhookRequest", ] diff --git a/openapi_core/contrib/requests/protocols.py b/openapi_core/contrib/requests/protocols.py new file mode 100644 index 00000000..9e4137e8 --- /dev/null +++ b/openapi_core/contrib/requests/protocols.py @@ -0,0 +1,9 @@ +from typing import Protocol +from typing import runtime_checkable + +from requests.cookies import RequestsCookieJar + + +@runtime_checkable +class SupportsCookieJar(Protocol): + _cookies: RequestsCookieJar diff --git a/openapi_core/contrib/requests/requests.py b/openapi_core/contrib/requests/requests.py index 12921d9d..2a686fcc 100644 --- a/openapi_core/contrib/requests/requests.py +++ b/openapi_core/contrib/requests/requests.py @@ -1,34 +1,97 @@ """OpenAPI core contrib requests requests module""" + +from typing import Optional +from typing import Union +from urllib.parse import parse_qs +from urllib.parse import urlparse + +from requests import PreparedRequest +from requests import Request +from requests.cookies import RequestsCookieJar +from werkzeug.datastructures import Headers from werkzeug.datastructures import ImmutableMultiDict -from openapi_core.validation.request.datatypes import ( - RequestParameters, OpenAPIRequest, -) +from openapi_core.contrib.requests.protocols import SupportsCookieJar +from openapi_core.datatypes import RequestParameters + +class RequestsOpenAPIRequest: + """ + Converts a requests request to an OpenAPI request -class RequestsOpenAPIRequestFactory(object): + Internally converts to a `PreparedRequest` first to parse the exact + payload being sent + """ - @classmethod - def create(cls, request): - method = request.method.lower() + def __init__(self, request: Union[Request, PreparedRequest]): + if not isinstance(request, (Request, PreparedRequest)): + raise TypeError( + "'request' argument is not type of " + f"{Request} or {PreparedRequest}" + ) + if isinstance(request, Request): + request = request.prepare() - cookie = request.cookies or {} + self.request = request + if request.url is None: + raise RuntimeError("Request URL is missing") + self._url_parsed = urlparse(request.url, allow_fragments=False) - # gets deduced by path finder against spec - path = {} + cookie = {} + if isinstance(self.request, SupportsCookieJar) and isinstance( + self.request._cookies, RequestsCookieJar + ): + # cookies are stored in a cookiejar object + cookie = self.request._cookies.get_dict() - mimetype = request.headers.get('Accept') or \ - request.headers.get('Content-Type') - parameters = RequestParameters( - query=ImmutableMultiDict(request.params), - header=request.headers, - cookie=cookie, - path=path, + self.parameters = RequestParameters( + query=ImmutableMultiDict(parse_qs(self._url_parsed.query)), + header=Headers(dict(self.request.headers)), + cookie=ImmutableMultiDict(cookie), ) - return OpenAPIRequest( - full_url_pattern=request.url, - method=method, - parameters=parameters, - body=request.data, - mimetype=mimetype, + + @property + def host_url(self) -> str: + return f"{self._url_parsed.scheme}://{self._url_parsed.netloc}" + + @property + def path(self) -> str: + assert isinstance(self._url_parsed.path, str) + return self._url_parsed.path + + @property + def method(self) -> str: + method = self.request.method + return method and method.lower() or "" + + @property + def body(self) -> Optional[bytes]: + if self.request.body is None: + return None + if isinstance(self.request.body, bytes): + return self.request.body + assert isinstance(self.request.body, str) + # TODO: figure out if request._body_position is relevant + return self.request.body.encode("utf-8") + + @property + def content_type(self) -> str: + # Order matters because all python requests issued from a session + # include Accept */* which does not necessarily match the content type + return str( + self.request.headers.get("Content-Type") + or self.request.headers.get("Accept") ) + + +class RequestsOpenAPIWebhookRequest(RequestsOpenAPIRequest): + """ + Converts a requests request to an OpenAPI Webhook request + + Internally converts to a `PreparedRequest` first to parse the exact + payload being sent + """ + + def __init__(self, request: Union[Request, PreparedRequest], name: str): + super().__init__(request) + self.name = name diff --git a/openapi_core/contrib/requests/responses.py b/openapi_core/contrib/requests/responses.py index 502d6b9b..4570ba79 100644 --- a/openapi_core/contrib/requests/responses.py +++ b/openapi_core/contrib/requests/responses.py @@ -1,14 +1,28 @@ """OpenAPI core contrib requests responses module""" -from openapi_core.validation.response.datatypes import OpenAPIResponse +from requests import Response +from werkzeug.datastructures import Headers -class RequestsOpenAPIResponseFactory(object): - @classmethod - def create(cls, response): - mimetype = response.headers.get('Content-Type') - return OpenAPIResponse( - data=response.content, - status_code=response.status_code, - mimetype=mimetype, - ) +class RequestsOpenAPIResponse: + def __init__(self, response: Response): + if not isinstance(response, Response): + raise TypeError(f"'response' argument is not type of {Response}") + self.response = response + + @property + def data(self) -> bytes: + assert isinstance(self.response.content, bytes) + return self.response.content + + @property + def status_code(self) -> int: + return int(self.response.status_code) + + @property + def content_type(self) -> str: + return str(self.response.headers.get("Content-Type", "")) + + @property + def headers(self) -> Headers: + return Headers(dict(self.response.headers)) diff --git a/openapi_core/contrib/starlette/__init__.py b/openapi_core/contrib/starlette/__init__.py new file mode 100644 index 00000000..74842b99 --- /dev/null +++ b/openapi_core/contrib/starlette/__init__.py @@ -0,0 +1,7 @@ +from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest +from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse + +__all__ = [ + "StarletteOpenAPIRequest", + "StarletteOpenAPIResponse", +] diff --git a/openapi_core/contrib/starlette/handlers.py b/openapi_core/contrib/starlette/handlers.py new file mode 100644 index 00000000..daed2c42 --- /dev/null +++ b/openapi_core/contrib/starlette/handlers.py @@ -0,0 +1,65 @@ +"""OpenAPI core contrib starlette handlers module""" + +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Type + +from starlette.middleware.base import RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.responses import Response + +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult + + +class StarletteOpenAPIErrorsHandler: + OPENAPI_ERROR_STATUS: Dict[Type[BaseException], int] = { + ServerNotFound: 400, + SecurityNotFound: 403, + OperationNotFound: 405, + PathNotFound: 404, + MediaTypeNotFound: 415, + } + + def __call__( + self, + errors: Iterable[Exception], + ) -> JSONResponse: + data_errors = [self.format_openapi_error(err) for err in errors] + data = { + "errors": data_errors, + } + data_error_max = max(data_errors, key=self.get_error_status) + return JSONResponse(data, status_code=data_error_max["status"]) + + @classmethod + def format_openapi_error(cls, error: BaseException) -> Dict[str, Any]: + if error.__cause__ is not None: + error = error.__cause__ + return { + "title": str(error), + "status": cls.OPENAPI_ERROR_STATUS.get(error.__class__, 400), + "type": str(type(error)), + } + + @classmethod + def get_error_status(cls, error: Dict[str, Any]) -> str: + return str(error["status"]) + + +class StarletteOpenAPIValidRequestHandler: + def __init__(self, request: Request, call_next: RequestResponseEndpoint): + self.request = request + self.call_next = call_next + + async def __call__( + self, request_unmarshal_result: RequestUnmarshalResult + ) -> Response: + self.request.scope["openapi"] = request_unmarshal_result + return await self.call_next(self.request) diff --git a/openapi_core/contrib/starlette/integrations.py b/openapi_core/contrib/starlette/integrations.py new file mode 100644 index 00000000..4667fe01 --- /dev/null +++ b/openapi_core/contrib/starlette/integrations.py @@ -0,0 +1,52 @@ +from aioitertools.itertools import tee as atee +from starlette.requests import Request +from starlette.responses import Response + +from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest +from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse +from openapi_core.unmarshalling.processors import AsyncUnmarshallingProcessor +from openapi_core.unmarshalling.typing import ErrorsHandlerCallable + + +class StarletteIntegration(AsyncUnmarshallingProcessor[Request, Response]): + request_cls = StarletteOpenAPIRequest + response_cls = StarletteOpenAPIResponse + + async def get_openapi_request( + self, request: Request + ) -> StarletteOpenAPIRequest: + body = await request.body() + return self.request_cls(request, body) + + async def get_openapi_response( + self, response: Response + ) -> StarletteOpenAPIResponse: + assert self.response_cls is not None + data = None + if hasattr(response, "body_iterator"): + body_iter1, body_iter2 = atee(response.body_iterator) + response.body_iterator = body_iter2 + data = b"".join( + [ + ( + chunk.encode(response.charset) + if not isinstance(chunk, bytes) + else chunk + ) + async for chunk in body_iter1 + ] + ) + return self.response_cls(response, data=data) + + def should_validate_response(self) -> bool: + return self.response_cls is not None + + async def handle_response( + self, + request: Request, + response: Response, + errors_handler: ErrorsHandlerCallable[Response], + ) -> Response: + if not self.should_validate_response(): + return response + return await super().handle_response(request, response, errors_handler) diff --git a/openapi_core/contrib/starlette/middlewares.py b/openapi_core/contrib/starlette/middlewares.py new file mode 100644 index 00000000..2b0b9368 --- /dev/null +++ b/openapi_core/contrib/starlette/middlewares.py @@ -0,0 +1,38 @@ +"""OpenAPI core contrib starlette middlewares module""" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.base import RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + +from openapi_core import OpenAPI +from openapi_core.contrib.starlette.handlers import ( + StarletteOpenAPIErrorsHandler, +) +from openapi_core.contrib.starlette.handlers import ( + StarletteOpenAPIValidRequestHandler, +) +from openapi_core.contrib.starlette.integrations import StarletteIntegration + + +class StarletteOpenAPIMiddleware(StarletteIntegration, BaseHTTPMiddleware): + valid_request_handler_cls = StarletteOpenAPIValidRequestHandler + errors_handler = StarletteOpenAPIErrorsHandler() + + def __init__(self, app: ASGIApp, openapi: OpenAPI): + super().__init__(openapi) + BaseHTTPMiddleware.__init__(self, app) + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + valid_request_handler = self.valid_request_handler_cls( + request, call_next + ) + response = await self.handle_request( + request, valid_request_handler, self.errors_handler + ) + return await self.handle_response( + request, response, self.errors_handler + ) diff --git a/openapi_core/contrib/starlette/requests.py b/openapi_core/contrib/starlette/requests.py new file mode 100644 index 00000000..2e3494ba --- /dev/null +++ b/openapi_core/contrib/starlette/requests.py @@ -0,0 +1,46 @@ +"""OpenAPI core contrib starlette requests module""" + +from typing import Optional + +from starlette.requests import Request + +from openapi_core.datatypes import RequestParameters + + +class StarletteOpenAPIRequest: + def __init__(self, request: Request, body: Optional[bytes] = None): + if not isinstance(request, Request): + raise TypeError(f"'request' argument is not type of {Request}") + self.request = request + + self.parameters = RequestParameters( + query=self.request.query_params, + header=self.request.headers, + cookie=self.request.cookies, + ) + + self._body = body + + @property + def host_url(self) -> str: + return self.request.base_url._url + + @property + def path(self) -> str: + return self.request.url.path + + @property + def method(self) -> str: + return self.request.method.lower() + + @property + def body(self) -> Optional[bytes]: + return self._body + + @property + def content_type(self) -> str: + # default value according to RFC 2616 + return ( + self.request.headers.get("Content-Type") + or "application/octet-stream" + ) diff --git a/openapi_core/contrib/starlette/responses.py b/openapi_core/contrib/starlette/responses.py new file mode 100644 index 00000000..9944663d --- /dev/null +++ b/openapi_core/contrib/starlette/responses.py @@ -0,0 +1,41 @@ +"""OpenAPI core contrib starlette responses module""" + +from typing import Optional + +from starlette.datastructures import Headers +from starlette.responses import Response +from starlette.responses import StreamingResponse + + +class StarletteOpenAPIResponse: + def __init__(self, response: Response, data: Optional[bytes] = None): + if not isinstance(response, Response): + raise TypeError(f"'response' argument is not type of {Response}") + self.response = response + + if data is None and isinstance(response, StreamingResponse): + raise RuntimeError( + f"'data' argument is required for {StreamingResponse}" + ) + self._data = data + + @property + def data(self) -> bytes: + if self._data is not None: + return self._data + if isinstance(self.response.body, bytes): + return self.response.body + assert isinstance(self.response.body, str) + return self.response.body.encode("utf-8") + + @property + def status_code(self) -> int: + return self.response.status_code + + @property + def content_type(self) -> str: + return self.response.headers.get("Content-Type") or "" + + @property + def headers(self) -> Headers: + return self.response.headers diff --git a/openapi_core/contrib/werkzeug/__init__.py b/openapi_core/contrib/werkzeug/__init__.py new file mode 100644 index 00000000..91eda4cc --- /dev/null +++ b/openapi_core/contrib/werkzeug/__init__.py @@ -0,0 +1,7 @@ +from openapi_core.contrib.werkzeug.requests import WerkzeugOpenAPIRequest +from openapi_core.contrib.werkzeug.responses import WerkzeugOpenAPIResponse + +__all__ = [ + "WerkzeugOpenAPIRequest", + "WerkzeugOpenAPIResponse", +] diff --git a/openapi_core/contrib/werkzeug/requests.py b/openapi_core/contrib/werkzeug/requests.py new file mode 100644 index 00000000..4b979c13 --- /dev/null +++ b/openapi_core/contrib/werkzeug/requests.py @@ -0,0 +1,52 @@ +"""OpenAPI core contrib werkzeug requests module""" + +import re +from typing import Optional + +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict +from werkzeug.wrappers import Request + +from openapi_core.datatypes import RequestParameters + +# http://flask.pocoo.org/docs/1.0/quickstart/#variable-rules +PATH_PARAMETER_PATTERN = r"<(?:(?:string|int|float|path|uuid):)?(\w+)>" + + +class WerkzeugOpenAPIRequest: + path_regex = re.compile(PATH_PARAMETER_PATTERN) + + def __init__(self, request: Request): + if not isinstance(request, Request): + raise TypeError(f"'request' argument is not type of {Request}") + self.request = request + + self.parameters = RequestParameters( + query=ImmutableMultiDict(self.request.args), + header=Headers(self.request.headers), + cookie=self.request.cookies, + ) + + @property + def host_url(self) -> str: + return self.request.host_url + + @property + def path(self) -> str: + return self.get_path(self.request.path) + + @property + def method(self) -> str: + return self.request.method.lower() + + @property + def body(self) -> Optional[bytes]: + return self.request.get_data(as_text=False) + + @property + def content_type(self) -> str: + # default value according to RFC 2616 + return self.request.content_type or "application/octet-stream" + + def get_path(self, path: str) -> str: + return "".join([self.request.root_path, path]) diff --git a/openapi_core/contrib/werkzeug/responses.py b/openapi_core/contrib/werkzeug/responses.py new file mode 100644 index 00000000..b8afeea4 --- /dev/null +++ b/openapi_core/contrib/werkzeug/responses.py @@ -0,0 +1,33 @@ +"""OpenAPI core contrib werkzeug responses module""" + +from itertools import tee + +from werkzeug.datastructures import Headers +from werkzeug.wrappers import Response + + +class WerkzeugOpenAPIResponse: + def __init__(self, response: Response): + if not isinstance(response, Response): + raise TypeError(f"'response' argument is not type of {Response}") + self.response = response + + @property + def data(self) -> bytes: + if not self.response.is_sequence: + resp_iter1, resp_iter2 = tee(self.response.iter_encoded()) + self.response.response = resp_iter1 + return b"".join(resp_iter2) + return self.response.get_data(as_text=False) + + @property + def status_code(self) -> int: + return self.response._status_code + + @property + def content_type(self) -> str: + return str(self.response.mimetype) + + @property + def headers(self) -> Headers: + return Headers(self.response.headers) diff --git a/openapi_core/datatypes.py b/openapi_core/datatypes.py new file mode 100644 index 00000000..38746aff --- /dev/null +++ b/openapi_core/datatypes.py @@ -0,0 +1,43 @@ +"""OpenAPI core validation request datatypes module""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Mapping + +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict + + +@dataclass +class RequestParameters: + """OpenAPI request parameters dataclass. + + Attributes: + query + Query string parameters as MultiDict. Must support getlist method. + header + Request headers as Headers. + cookie + Request cookies as MultiDict. + path + Path parameters as dict. Gets resolved against spec if empty. + """ + + query: Mapping[str, Any] = field(default_factory=ImmutableMultiDict) + header: Mapping[str, Any] = field(default_factory=Headers) + cookie: Mapping[str, Any] = field(default_factory=ImmutableMultiDict) + path: Mapping[str, Any] = field(default_factory=dict) + + def __getitem__(self, location: str) -> Any: + return getattr(self, location) + + +@dataclass +class Parameters: + query: Mapping[str, Any] = field(default_factory=dict) + header: Mapping[str, Any] = field(default_factory=dict) + cookie: Mapping[str, Any] = field(default_factory=dict) + path: Mapping[str, Any] = field(default_factory=dict) diff --git a/openapi_core/deserializing/exceptions.py b/openapi_core/deserializing/exceptions.py index 2ff5774e..f2a0d834 100644 --- a/openapi_core/deserializing/exceptions.py +++ b/openapi_core/deserializing/exceptions.py @@ -1,14 +1,5 @@ -import attr - from openapi_core.exceptions import OpenAPIError -@attr.s(hash=True) class DeserializeError(OpenAPIError): """Deserialize operation error""" - value = attr.ib() - style = attr.ib() - - def __str__(self): - return "Failed to deserialize value {value} with style {style}".format( - value=self.value, style=self.style) diff --git a/openapi_core/deserializing/media_types/__init__.py b/openapi_core/deserializing/media_types/__init__.py index e69de29b..fd4a0ae1 100644 --- a/openapi_core/deserializing/media_types/__init__.py +++ b/openapi_core/deserializing/media_types/__init__.py @@ -0,0 +1,37 @@ +from collections import defaultdict + +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.media_types.util import binary_loads +from openapi_core.deserializing.media_types.util import data_form_loads +from openapi_core.deserializing.media_types.util import json_loads +from openapi_core.deserializing.media_types.util import plain_loads +from openapi_core.deserializing.media_types.util import urlencoded_form_loads +from openapi_core.deserializing.media_types.util import xml_loads +from openapi_core.deserializing.styles import style_deserializers_factory + +__all__ = ["media_type_deserializers_factory"] + +media_type_deserializers: MediaTypeDeserializersDict = defaultdict( + lambda: binary_loads, + **{ + "text/html": plain_loads, + "text/plain": plain_loads, + "application/octet-stream": binary_loads, + "application/json": json_loads, + "application/vnd.api+json": json_loads, + "application/xml": xml_loads, + "application/xhtml+xml": xml_loads, + "application/x-www-form-urlencoded": urlencoded_form_loads, + "multipart/form-data": data_form_loads, + } +) + +media_type_deserializers_factory = MediaTypeDeserializersFactory( + style_deserializers_factory, + media_type_deserializers=media_type_deserializers, +) diff --git a/openapi_core/deserializing/media_types/datatypes.py b/openapi_core/deserializing/media_types/datatypes.py new file mode 100644 index 00000000..4d8f8fd8 --- /dev/null +++ b/openapi_core/deserializing/media_types/datatypes.py @@ -0,0 +1,6 @@ +from typing import Any +from typing import Callable +from typing import Dict + +DeserializerCallable = Callable[[bytes], Any] +MediaTypeDeserializersDict = Dict[str, DeserializerCallable] diff --git a/openapi_core/deserializing/media_types/deserializers.py b/openapi_core/deserializing/media_types/deserializers.py index 4839acdb..a03c7e0d 100644 --- a/openapi_core/deserializing/media_types/deserializers.py +++ b/openapi_core/deserializing/media_types/deserializers.py @@ -1,14 +1,193 @@ -from openapi_core.deserializing.exceptions import DeserializeError +from typing import Any +from typing import Mapping +from typing import Optional +from xml.etree.ElementTree import ParseError +from jsonschema_path import SchemaPath -class PrimitiveDeserializer(object): +from openapi_core.deserializing.media_types.datatypes import ( + DeserializerCallable, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.exceptions import ( + MediaTypeDeserializeError, +) +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.schema.encodings import get_content_type +from openapi_core.schema.parameters import get_style_and_explode +from openapi_core.schema.protocols import SuportsGetAll +from openapi_core.schema.protocols import SuportsGetList +from openapi_core.schema.schemas import get_properties - def __init__(self, mimetype, deserializer_callable): - self.mimetype = mimetype - self.deserializer_callable = deserializer_callable - def __call__(self, value): +class MediaTypesDeserializer: + def __init__( + self, + media_type_deserializers: Optional[MediaTypeDeserializersDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + ): + if media_type_deserializers is None: + media_type_deserializers = {} + self.media_type_deserializers = media_type_deserializers + if extra_media_type_deserializers is None: + extra_media_type_deserializers = {} + self.extra_media_type_deserializers = extra_media_type_deserializers + + def deserialize( + self, mimetype: str, value: bytes, **parameters: str + ) -> Any: + deserializer_callable = self.get_deserializer_callable(mimetype) + try: - return self.deserializer_callable(value) - except (ValueError, TypeError, AttributeError): - raise DeserializeError(value, self.mimetype) + return deserializer_callable(value, **parameters) + except (ParseError, ValueError, TypeError, AttributeError): + raise MediaTypeDeserializeError(mimetype, value) + + def get_deserializer_callable( + self, + mimetype: str, + ) -> DeserializerCallable: + if mimetype in self.extra_media_type_deserializers: + return self.extra_media_type_deserializers[mimetype] + return self.media_type_deserializers[mimetype] + + +class MediaTypeDeserializer: + def __init__( + self, + style_deserializers_factory: StyleDeserializersFactory, + media_types_deserializer: MediaTypesDeserializer, + mimetype: str, + schema: Optional[SchemaPath] = None, + encoding: Optional[SchemaPath] = None, + **parameters: str, + ): + self.style_deserializers_factory = style_deserializers_factory + self.media_types_deserializer = media_types_deserializer + self.mimetype = mimetype + self.schema = schema + self.encoding = encoding + self.parameters = parameters + + def deserialize(self, value: bytes) -> Any: + deserialized = self.media_types_deserializer.deserialize( + self.mimetype, value, **self.parameters + ) + + if ( + self.mimetype != "application/x-www-form-urlencoded" + and not self.mimetype.startswith("multipart") + ): + return deserialized + + # decode multipart request bodies + return self.decode(deserialized) + + def evolve( + self, mimetype: str, schema: Optional[SchemaPath] + ) -> "MediaTypeDeserializer": + cls = self.__class__ + + return cls( + self.style_deserializers_factory, + self.media_types_deserializer, + mimetype, + schema=schema, + ) + + def decode(self, location: Mapping[str, Any]) -> Mapping[str, Any]: + # schema is required for multipart + assert self.schema is not None + properties = {} + for prop_name, prop_schema in get_properties(self.schema).items(): + try: + properties[prop_name] = self.decode_property( + prop_name, prop_schema, location + ) + except KeyError: + if "default" not in prop_schema: + continue + properties[prop_name] = prop_schema["default"] + + return properties + + def decode_property( + self, + prop_name: str, + prop_schema: SchemaPath, + location: Mapping[str, Any], + ) -> Any: + if self.encoding is None or prop_name not in self.encoding: + if self.mimetype == "application/x-www-form-urlencoded": + # default serialization strategy for complex objects + # in the application/x-www-form-urlencoded + return self.decode_property_style( + prop_name, + prop_schema, + location, + SchemaPath.from_dict({"style": "form"}), + ) + return self.decode_property_content_type( + prop_name, prop_schema, location + ) + + prep_encoding = self.encoding / prop_name + if ( + "style" not in prep_encoding + and "explode" not in prep_encoding + and "allowReserved" not in prep_encoding + ): + return self.decode_property_content_type( + prop_name, prop_schema, location, prep_encoding + ) + + return self.decode_property_style( + prop_name, prop_schema, location, prep_encoding + ) + + def decode_property_style( + self, + prop_name: str, + prop_schema: SchemaPath, + location: Mapping[str, Any], + prep_encoding: SchemaPath, + ) -> Any: + prop_style, prop_explode = get_style_and_explode( + prep_encoding, default_location="query" + ) + prop_deserializer = self.style_deserializers_factory.create( + prop_style, prop_explode, prop_schema, name=prop_name + ) + return prop_deserializer.deserialize(location) + + def decode_property_content_type( + self, + prop_name: str, + prop_schema: SchemaPath, + location: Mapping[str, Any], + prop_encoding: Optional[SchemaPath] = None, + ) -> Any: + prop_content_type = get_content_type(prop_schema, prop_encoding) + prop_deserializer = self.evolve( + prop_content_type, + prop_schema, + ) + prop_schema_type = prop_schema.getkey("type", "") + if ( + self.mimetype.startswith("multipart") + and prop_schema_type == "array" + ): + if isinstance(location, SuportsGetAll): + value = location.getall(prop_name) + return list(map(prop_deserializer.deserialize, value)) + if isinstance(location, SuportsGetList): + value = location.getlist(prop_name) + return list(map(prop_deserializer.deserialize, value)) + + return prop_deserializer.deserialize(location[prop_name]) diff --git a/openapi_core/deserializing/media_types/exceptions.py b/openapi_core/deserializing/media_types/exceptions.py new file mode 100644 index 00000000..a5ecfeb4 --- /dev/null +++ b/openapi_core/deserializing/media_types/exceptions.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from openapi_core.deserializing.exceptions import DeserializeError + + +@dataclass +class MediaTypeDeserializeError(DeserializeError): + """Media type deserialize operation error""" + + mimetype: str + value: bytes + + def __str__(self) -> str: + return ( + "Failed to deserialize value with {mimetype} mimetype: {value}" + ).format(value=self.value.decode("utf-8"), mimetype=self.mimetype) diff --git a/openapi_core/deserializing/media_types/factories.py b/openapi_core/deserializing/media_types/factories.py index a6701c1f..45bc5075 100644 --- a/openapi_core/deserializing/media_types/factories.py +++ b/openapi_core/deserializing/media_types/factories.py @@ -1,28 +1,57 @@ -from openapi_core.deserializing.media_types.util import json_loads +from typing import Mapping +from typing import Optional +from jsonschema_path import SchemaPath + +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) from openapi_core.deserializing.media_types.deserializers import ( - PrimitiveDeserializer, + MediaTypeDeserializer, +) +from openapi_core.deserializing.media_types.deserializers import ( + MediaTypesDeserializer, +) +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, ) -class MediaTypeDeserializersFactory(object): - - MEDIA_TYPE_DESERIALIZERS = { - 'application/json': json_loads, - } - - def __init__(self, custom_deserializers=None): - if custom_deserializers is None: - custom_deserializers = {} - self.custom_deserializers = custom_deserializers +class MediaTypeDeserializersFactory: + def __init__( + self, + style_deserializers_factory: StyleDeserializersFactory, + media_type_deserializers: Optional[MediaTypeDeserializersDict] = None, + ): + self.style_deserializers_factory = style_deserializers_factory + if media_type_deserializers is None: + media_type_deserializers = {} + self.media_type_deserializers = media_type_deserializers - def create(self, media_type): - deserialize_callable = self.get_deserializer_callable( - media_type.mimetype) - return PrimitiveDeserializer( - media_type.mimetype, deserialize_callable) + def create( + self, + mimetype: str, + schema: Optional[SchemaPath] = None, + parameters: Optional[Mapping[str, str]] = None, + encoding: Optional[SchemaPath] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + ) -> MediaTypeDeserializer: + if parameters is None: + parameters = {} + if extra_media_type_deserializers is None: + extra_media_type_deserializers = {} + media_types_deserializer = MediaTypesDeserializer( + self.media_type_deserializers, + extra_media_type_deserializers, + ) - def get_deserializer_callable(self, mimetype): - if mimetype in self.custom_deserializers: - return self.custom_deserializers[mimetype] - return self.MEDIA_TYPE_DESERIALIZERS.get(mimetype, lambda x: x) + return MediaTypeDeserializer( + self.style_deserializers_factory, + media_types_deserializer, + mimetype, + schema=schema, + encoding=encoding, + **parameters, + ) diff --git a/openapi_core/deserializing/media_types/util.py b/openapi_core/deserializing/media_types/util.py index e09c1d16..520e20b3 100644 --- a/openapi_core/deserializing/media_types/util.py +++ b/openapi_core/deserializing/media_types/util.py @@ -1,10 +1,73 @@ +from email.message import Message +from email.parser import Parser from json import loads +from typing import Any +from typing import Iterator +from typing import Mapping +from typing import Tuple +from urllib.parse import parse_qsl +from xml.etree.ElementTree import Element +from xml.etree.ElementTree import fromstring -from six import binary_type +from werkzeug.datastructures import ImmutableMultiDict -def json_loads(value): - # python 3.5 doesn't support binary input fix - if isinstance(value, (binary_type, )): - value = value.decode() +def binary_loads(value: bytes, **parameters: str) -> bytes: + return value + + +def plain_loads(value: bytes, **parameters: str) -> str: + charset = "utf-8" + if "charset" in parameters: + charset = parameters["charset"] + if isinstance(value, bytes): + try: + return value.decode(charset) + # fallback safe decode + except UnicodeDecodeError: + return value.decode("ASCII", errors="surrogateescape") + return value + + +def json_loads(value: bytes, **parameters: str) -> Any: return loads(value) + + +def xml_loads(value: bytes, **parameters: str) -> Element: + charset = "utf-8" + if "charset" in parameters: + charset = parameters["charset"] + return fromstring(value.decode(charset)) + + +def urlencoded_form_loads( + value: bytes, **parameters: str +) -> Mapping[str, Any]: + # only UTF-8 is conforming + return ImmutableMultiDict(parse_qsl(value.decode("utf-8"))) + + +def data_form_loads(value: bytes, **parameters: str) -> Mapping[str, Any]: + charset = "ASCII" + if "charset" in parameters: + charset = parameters["charset"] + decoded = value.decode(charset, errors="surrogateescape") + boundary = "" + if "boundary" in parameters: + boundary = parameters["boundary"] + parser = Parser() + mimetype = "multipart/form-data" + header = f'Content-Type: {mimetype}; boundary="{boundary}"' + text = "\n\n".join([header, decoded]) + parts = parser.parsestr(text, headersonly=False) + return ImmutableMultiDict(list(iter_payloads(parts))) + + +def iter_payloads(parts: Message) -> Iterator[Tuple[str, bytes]]: + for part in parts.get_payload(): + assert isinstance(part, Message) + name = part.get_param("name", header="content-disposition") + assert isinstance(name, str) + payload = part.get_payload(decode=True) + assert isinstance(payload, bytes) + yield name, payload diff --git a/openapi_core/deserializing/parameters/deserializers.py b/openapi_core/deserializing/parameters/deserializers.py deleted file mode 100644 index 368acdee..00000000 --- a/openapi_core/deserializing/parameters/deserializers.py +++ /dev/null @@ -1,25 +0,0 @@ -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.deserializing.parameters.exceptions import ( - EmptyParameterValue, -) -from openapi_core.schema.parameters.enums import ParameterLocation - - -class PrimitiveDeserializer(object): - - def __init__(self, param, deserializer_callable): - self.param = param - self.deserializer_callable = deserializer_callable - - def __call__(self, value): - if (self.param.location == ParameterLocation.QUERY and value == "" and - not self.param.allow_empty_value): - raise EmptyParameterValue( - value, self.param.style, self.param.name) - - if not self.param.aslist or self.param.explode: - return value - try: - return self.deserializer_callable(value) - except (ValueError, TypeError, AttributeError): - raise DeserializeError(value, self.param.style) diff --git a/openapi_core/deserializing/parameters/exceptions.py b/openapi_core/deserializing/parameters/exceptions.py deleted file mode 100644 index 6187f2c0..00000000 --- a/openapi_core/deserializing/parameters/exceptions.py +++ /dev/null @@ -1,11 +0,0 @@ -import attr - -from openapi_core.deserializing.exceptions import DeserializeError - - -@attr.s(hash=True) -class EmptyParameterValue(DeserializeError): - name = attr.ib() - - def __str__(self): - return "Value of parameter cannot be empty: {0}".format(self.name) diff --git a/openapi_core/deserializing/parameters/factories.py b/openapi_core/deserializing/parameters/factories.py deleted file mode 100644 index 5893619d..00000000 --- a/openapi_core/deserializing/parameters/factories.py +++ /dev/null @@ -1,26 +0,0 @@ -import warnings - -from openapi_core.deserializing.parameters.deserializers import ( - PrimitiveDeserializer, -) -from openapi_core.schema.parameters.enums import ParameterStyle - - -class ParameterDeserializersFactory(object): - - PARAMETER_STYLE_DESERIALIZERS = { - ParameterStyle.FORM: lambda x: x.split(','), - ParameterStyle.SIMPLE: lambda x: x.split(','), - ParameterStyle.SPACE_DELIMITED: lambda x: x.split(' '), - ParameterStyle.PIPE_DELIMITED: lambda x: x.split('|'), - } - - def create(self, param): - if param.deprecated: - warnings.warn( - "{0} parameter is deprecated".format(param.name), - DeprecationWarning, - ) - - deserialize_callable = self.PARAMETER_STYLE_DESERIALIZERS[param.style] - return PrimitiveDeserializer(param, deserialize_callable) diff --git a/openapi_core/deserializing/styles/__init__.py b/openapi_core/deserializing/styles/__init__.py new file mode 100644 index 00000000..f9ecef06 --- /dev/null +++ b/openapi_core/deserializing/styles/__init__.py @@ -0,0 +1,27 @@ +from openapi_core.deserializing.styles.datatypes import StyleDeserializersDict +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.deserializing.styles.util import deep_object_loads +from openapi_core.deserializing.styles.util import form_loads +from openapi_core.deserializing.styles.util import label_loads +from openapi_core.deserializing.styles.util import matrix_loads +from openapi_core.deserializing.styles.util import pipe_delimited_loads +from openapi_core.deserializing.styles.util import simple_loads +from openapi_core.deserializing.styles.util import space_delimited_loads + +__all__ = ["style_deserializers_factory"] + +style_deserializers: StyleDeserializersDict = { + "matrix": matrix_loads, + "label": label_loads, + "form": form_loads, + "simple": simple_loads, + "spaceDelimited": space_delimited_loads, + "pipeDelimited": pipe_delimited_loads, + "deepObject": deep_object_loads, +} + +style_deserializers_factory = StyleDeserializersFactory( + style_deserializers=style_deserializers, +) diff --git a/openapi_core/deserializing/styles/datatypes.py b/openapi_core/deserializing/styles/datatypes.py new file mode 100644 index 00000000..27fc7f6c --- /dev/null +++ b/openapi_core/deserializing/styles/datatypes.py @@ -0,0 +1,7 @@ +from typing import Any +from typing import Callable +from typing import Dict +from typing import Mapping + +DeserializerCallable = Callable[[bool, str, str, Mapping[str, Any]], Any] +StyleDeserializersDict = Dict[str, DeserializerCallable] diff --git a/openapi_core/deserializing/styles/deserializers.py b/openapi_core/deserializing/styles/deserializers.py new file mode 100644 index 00000000..2303f7a3 --- /dev/null +++ b/openapi_core/deserializing/styles/deserializers.py @@ -0,0 +1,35 @@ +import warnings +from typing import Any +from typing import Mapping +from typing import Optional + +from openapi_core.deserializing.exceptions import DeserializeError +from openapi_core.deserializing.styles.datatypes import DeserializerCallable + + +class StyleDeserializer: + def __init__( + self, + style: str, + explode: bool, + name: str, + schema_type: str, + deserializer_callable: Optional[DeserializerCallable] = None, + ): + self.style = style + self.explode = explode + self.name = name + self.schema_type = schema_type + self.deserializer_callable = deserializer_callable + + def deserialize(self, location: Mapping[str, Any]) -> Any: + if self.deserializer_callable is None: + warnings.warn(f"Unsupported {self.style} style") + return location[self.name] + + try: + return self.deserializer_callable( + self.explode, self.name, self.schema_type, location + ) + except (ValueError, TypeError, AttributeError): + raise DeserializeError(self.style, self.name) diff --git a/openapi_core/deserializing/styles/exceptions.py b/openapi_core/deserializing/styles/exceptions.py new file mode 100644 index 00000000..e423843f --- /dev/null +++ b/openapi_core/deserializing/styles/exceptions.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + +from openapi_core.deserializing.exceptions import DeserializeError + + +@dataclass +class BaseStyleDeserializeError(DeserializeError): + """Base style deserialize operation error""" + + location: str + + +@dataclass +class ParameterDeserializeError(BaseStyleDeserializeError): + """Parameter deserialize operation error""" + + style: str + value: str + + def __str__(self) -> str: + return ( + "Failed to deserialize value of " + f"{self.location} parameter with style {self.style}: {self.value}" + ) + + +@dataclass(init=False) +class EmptyQueryParameterValue(BaseStyleDeserializeError): + name: str + + def __init__(self, name: str): + super().__init__(location="query") + self.name = name + + def __str__(self) -> str: + return ( + f"Value of {self.name} {self.location} parameter cannot be empty" + ) diff --git a/openapi_core/deserializing/styles/factories.py b/openapi_core/deserializing/styles/factories.py new file mode 100644 index 00000000..5758d97d --- /dev/null +++ b/openapi_core/deserializing/styles/factories.py @@ -0,0 +1,30 @@ +from typing import Optional + +from jsonschema_path import SchemaPath + +from openapi_core.deserializing.styles.datatypes import StyleDeserializersDict +from openapi_core.deserializing.styles.deserializers import StyleDeserializer + + +class StyleDeserializersFactory: + def __init__( + self, + style_deserializers: Optional[StyleDeserializersDict] = None, + ): + if style_deserializers is None: + style_deserializers = {} + self.style_deserializers = style_deserializers + + def create( + self, + style: str, + explode: bool, + schema: SchemaPath, + name: str, + ) -> StyleDeserializer: + schema_type = schema.getkey("type", "") + + deserialize_callable = self.style_deserializers.get(style) + return StyleDeserializer( + style, explode, name, schema_type, deserialize_callable + ) diff --git a/openapi_core/deserializing/styles/util.py b/openapi_core/deserializing/styles/util.py new file mode 100644 index 00000000..8290b7b4 --- /dev/null +++ b/openapi_core/deserializing/styles/util.py @@ -0,0 +1,201 @@ +import re +from functools import partial +from typing import Any +from typing import List +from typing import Mapping + +from openapi_core.schema.protocols import SuportsGetAll +from openapi_core.schema.protocols import SuportsGetList + + +def split(value: str, separator: str = ",", step: int = 1) -> List[str]: + parts = value.split(separator) + + if step == 1: + return parts + + result = [] + for i in range(len(parts)): + if i % step == 0: + if i + 1 < len(parts): + result.append(parts[i] + separator + parts[i + 1]) + return result + + +def delimited_loads( + explode: bool, + name: str, + schema_type: str, + location: Mapping[str, Any], + delimiter: str, +) -> Any: + value = location[name] + + explode_type = (explode, schema_type) + if explode_type == (False, "array"): + return split(value, separator=delimiter) + if explode_type == (False, "object"): + return dict( + map( + partial(split, separator=delimiter), + split(value, separator=delimiter, step=2), + ) + ) + + raise ValueError("not available") + + +def matrix_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + if explode == False: + m = re.match(rf"^;{name}=(.*)$", location[f";{name}"]) + if m is None: + raise KeyError(name) + value = m.group(1) + # ;color=blue,black,brown + if schema_type == "array": + return split(value) + # ;color=R,100,G,200,B,150 + if schema_type == "object": + return dict(map(split, split(value, step=2))) + # .;color=blue + return value + else: + # ;color=blue;color=black;color=brown + if schema_type == "array": + return re.findall(rf";{name}=([^;]*)", location[f";{name}*"]) + # ;R=100;G=200;B=150 + if schema_type == "object": + value = location[f";{name}*"] + return dict( + map( + partial(split, separator="="), + split(value[1:], separator=";"), + ) + ) + # ;color=blue + m = re.match(rf"^;{name}=(.*)$", location[f";{name}*"]) + if m is None: + raise KeyError(name) + value = m.group(1) + return value + + +def label_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + if explode == False: + value = location[f".{name}"] + # .blue,black,brown + if schema_type == "array": + return split(value[1:]) + # .R,100,G,200,B,150 + if schema_type == "object": + return dict(map(split, split(value[1:], separator=",", step=2))) + # .blue + return value[1:] + else: + value = location[f".{name}*"] + # .blue.black.brown + if schema_type == "array": + return split(value[1:], separator=".") + # .R=100.G=200.B=150 + if schema_type == "object": + return dict( + map( + partial(split, separator="="), + split(value[1:], separator="."), + ) + ) + # .blue + return value[1:] + + +def form_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + explode_type = (explode, schema_type) + # color=blue,black,brown + if explode_type == (False, "array"): + return split(location[name], separator=",") + # color=blue&color=black&color=brown + elif explode_type == (True, "array"): + if name not in location: + raise KeyError(name) + if isinstance(location, SuportsGetAll): + return location.getall(name) + if isinstance(location, SuportsGetList): + return location.getlist(name) + return location[name] + + value = location[name] + # color=R,100,G,200,B,150 + if explode_type == (False, "object"): + return dict(map(split, split(value, separator=",", step=2))) + # R=100&G=200&B=150 + elif explode_type == (True, "object"): + return dict( + map(partial(split, separator="="), split(value, separator="&")) + ) + + # color=blue + return value + + +def simple_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + value = location[name] + + # blue,black,brown + if schema_type == "array": + return split(value, separator=",") + + explode_type = (explode, schema_type) + # R,100,G,200,B,150 + if explode_type == (False, "object"): + return dict(map(split, split(value, separator=",", step=2))) + # R=100,G=200,B=150 + elif explode_type == (True, "object"): + return dict( + map(partial(split, separator="="), split(value, separator=",")) + ) + + # blue + return value + + +def space_delimited_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + return delimited_loads( + explode, name, schema_type, location, delimiter="%20" + ) + + +def pipe_delimited_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + return delimited_loads(explode, name, schema_type, location, delimiter="|") + + +def deep_object_loads( + explode: bool, name: str, schema_type: str, location: Mapping[str, Any] +) -> Any: + explode_type = (explode, schema_type) + + if explode_type != (True, "object"): + raise ValueError("not available") + + keys_str = " ".join(location.keys()) + if not re.search(rf"{name}\[\w+\]", keys_str): + raise KeyError(name) + + values = {} + for key, value in location.items(): + # Split the key from the brackets. + key_split = re.split(pattern=r"\[|\]", string=key) + if key_split[0] == name: + values[key_split[1]] = value + return values diff --git a/openapi_core/exceptions.py b/openapi_core/exceptions.py index 504173c5..707b2ae1 100644 --- a/openapi_core/exceptions.py +++ b/openapi_core/exceptions.py @@ -3,3 +3,7 @@ class OpenAPIError(Exception): pass + + +class SpecError(OpenAPIError): + pass diff --git a/openapi_core/extensions/models/factories.py b/openapi_core/extensions/models/factories.py index ae00aecf..0f33b3cf 100644 --- a/openapi_core/extensions/models/factories.py +++ b/openapi_core/extensions/models/factories.py @@ -1,25 +1,47 @@ """OpenAPI X-Model extension factories module""" -from openapi_core.extensions.models.models import Model +from dataclasses import make_dataclass +from pydoc import locate +from typing import Any +from typing import Dict +from typing import Iterable +from typing import Type -class ModelClassFactory(object): +from jsonschema_path import SchemaPath - base_class = Model +from openapi_core.extensions.models.types import Field - def create(self, name): - return type(name, (self.base_class, ), {}) +class DictFactory: + base_class = dict -class ModelFactory(object): + def create( + self, schema: SchemaPath, fields: Iterable[Field] + ) -> Type[Dict[Any, Any]]: + return self.base_class - def __init__(self, model_class_factory=None): - self.model_class_factory = model_class_factory or ModelClassFactory() - def create(self, properties, name=None): - name = name or 'Model' +class ModelFactory(DictFactory): + def create( + self, + schema: SchemaPath, + fields: Iterable[Field], + ) -> Type[Any]: + name = schema.getkey("x-model") + if name is None: + return super().create(schema, fields) - model_class = self._create_class(name) - return model_class(properties) + return make_dataclass(name, fields, frozen=True) - def _create_class(self, name): - return self.model_class_factory.create(name) + +class ModelPathFactory(ModelFactory): + def create( + self, + schema: SchemaPath, + fields: Iterable[Field], + ) -> Any: + model_class_path = schema.getkey("x-model-path") + if model_class_path is None: + return super().create(schema, fields) + + return locate(model_class_path) diff --git a/openapi_core/extensions/models/models.py b/openapi_core/extensions/models/models.py deleted file mode 100644 index 751edbed..00000000 --- a/openapi_core/extensions/models/models.py +++ /dev/null @@ -1,26 +0,0 @@ -"""OpenAPI X-Model extension models module""" - - -class BaseModel(object): - """Base class for OpenAPI X-Model.""" - - @property - def __dict__(self): - raise NotImplementedError - - -class Model(BaseModel): - """Model class for OpenAPI X-Model.""" - - def __init__(self, properties=None): - self.__properties = properties or {} - - @property - def __dict__(self): - return self.__properties - - def __getattr__(self, name): - if name not in self.__properties: - raise AttributeError - - return self.__properties[name] diff --git a/openapi_core/extensions/models/types.py b/openapi_core/extensions/models/types.py new file mode 100644 index 00000000..c97af344 --- /dev/null +++ b/openapi_core/extensions/models/types.py @@ -0,0 +1,5 @@ +from typing import Any +from typing import Tuple +from typing import Union + +Field = Union[str, Tuple[str, Any]] diff --git a/openapi_core/protocols.py b/openapi_core/protocols.py new file mode 100644 index 00000000..160354f3 --- /dev/null +++ b/openapi_core/protocols.py @@ -0,0 +1,117 @@ +"""OpenAPI core protocols""" + +from typing import Any +from typing import Mapping +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from openapi_core.datatypes import RequestParameters + + +@runtime_checkable +class BaseRequest(Protocol): + parameters: RequestParameters + + @property + def method(self) -> str: + """The request method, as lowercase string.""" + + @property + def body(self) -> Optional[bytes]: + """The request body, as bytes (None if not provided).""" + + @property + def content_type(self) -> str: + """The content type with parameters (e.g., charset, boundary, etc.) and always lowercase.""" + + +@runtime_checkable +class Request(BaseRequest, Protocol): + """Request protocol. + + Attributes: + host_url: Url with scheme and host. + For example: https://localhost:8000 + path: Request path. + full_url_pattern: The matched url with scheme, host and path pattern. + For example: https://localhost:8000/api/v1/pets + https://localhost:8000/api/v1/pets/{pet_id} + method: The request method, as lowercase string. + parameters: A RequestParameters object. Needs to support path attribute setter + to write resolved path parameters. + content_type: The content type with parameters (e.g., charset, boundary, etc.) + and always lowercase. + body: The request body, as bytes (None if not provided). + """ + + @property + def host_url(self) -> str: + """Url with scheme and host. For example: https://localhost:8000""" + + @property + def path(self) -> str: + """Request path.""" + + +@runtime_checkable +class WebhookRequest(BaseRequest, Protocol): + """Webhook request protocol. + + Attributes: + name: Webhook name. + method: The request method, as lowercase string. + parameters: A RequestParameters object. Needs to support path attribute setter + to write resolved path parameters. + content_type: The content type with parameters (e.g., charset, boundary, etc.) + and always lowercase. + body: The request body, as bytes (None if not provided). + """ + + @property + def name(self) -> str: + """Webhook name.""" + + +@runtime_checkable +class SupportsPathPattern(Protocol): + """Supports path_pattern protocol. + + You also need to provide path variables in RequestParameters. + + Attributes: + path_pattern: The matched path pattern. + For example: /api/v1/pets/{pet_id} + """ + + @property + def path_pattern(self) -> str: + """The matched path pattern. For example: /api/v1/pets/{pet_id}""" + + +@runtime_checkable +class Response(Protocol): + """Response protocol. + + Attributes: + status_code: The status code as integer. + headers: Response headers as Headers. + content_type: The content type with parameters and always lowercase. + data: The response body, as bytes (None if not provided). + """ + + @property + def status_code(self) -> int: + """The status code as integer.""" + + @property + def content_type(self) -> str: + """The content type with parameters and always lowercase.""" + + @property + def headers(self) -> Mapping[str, Any]: + """Response headers as Headers.""" + + @property + def data(self) -> Optional[bytes]: + """The response body, as bytes (None if not provided).""" diff --git a/openapi_core/deserializing/parameters/__init__.py b/openapi_core/py.typed similarity index 100% rename from openapi_core/deserializing/parameters/__init__.py rename to openapi_core/py.typed diff --git a/openapi_core/schema/components/factories.py b/openapi_core/schema/components/factories.py deleted file mode 100644 index 6afab0a4..00000000 --- a/openapi_core/schema/components/factories.py +++ /dev/null @@ -1,60 +0,0 @@ -from openapi_core.compat import lru_cache -from openapi_core.schema.components.models import Components -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.schemas.generators import SchemasGenerator -from openapi_core.schema.security_schemes.generators import ( - SecuritySchemesGenerator, -) - - -class ComponentsFactory(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def create(self, components_spec): - components_deref = self.dereferencer.dereference(components_spec) - - schemas_spec = components_deref.get('schemas', {}) - responses_spec = components_deref.get('responses', {}) - parameters_spec = components_deref.get('parameters', {}) - request_bodies_spec = components_deref.get('requestBodies', {}) - security_schemes_spec = components_deref.get('securitySchemes', {}) - - extensions = self.extensions_generator.generate(components_deref) - - schemas = self.schemas_generator.generate(schemas_spec) - responses = self._generate_response(responses_spec) - parameters = self._generate_parameters(parameters_spec) - request_bodies = self._generate_request_bodies(request_bodies_spec) - security_schemes = self._generate_security_schemes( - security_schemes_spec) - return Components( - schemas=list(schemas), responses=responses, parameters=parameters, - request_bodies=request_bodies, security_schemes=security_schemes, - extensions=extensions, - ) - - @property - @lru_cache() - def schemas_generator(self): - return SchemasGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) - - def _generate_response(self, responses_spec): - return responses_spec - - def _generate_parameters(self, parameters_spec): - return parameters_spec - - def _generate_request_bodies(self, request_bodies_spec): - return request_bodies_spec - - def _generate_security_schemes(self, security_schemes_spec): - return SecuritySchemesGenerator(self.dereferencer).generate( - security_schemes_spec) diff --git a/openapi_core/schema/components/models.py b/openapi_core/schema/components/models.py deleted file mode 100644 index 11a605b2..00000000 --- a/openapi_core/schema/components/models.py +++ /dev/null @@ -1,15 +0,0 @@ -class Components(object): - """Represents an OpenAPI Components in a service.""" - - def __init__( - self, schemas=None, responses=None, parameters=None, - request_bodies=None, security_schemes=None, extensions=None): - self.schemas = schemas and dict(schemas) or {} - self.responses = responses and dict(responses) or {} - self.parameters = parameters and dict(parameters) or {} - self.request_bodies = request_bodies and dict(request_bodies) or {} - self.security_schemes = ( - security_schemes and dict(security_schemes) or {} - ) - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/contacts/factories.py b/openapi_core/schema/contacts/factories.py deleted file mode 100644 index d1724d9b..00000000 --- a/openapi_core/schema/contacts/factories.py +++ /dev/null @@ -1,25 +0,0 @@ -"""OpenAPI core contacts factories module""" -from openapi_core.compat import lru_cache -from openapi_core.schema.contacts.models import Contact -from openapi_core.schema.extensions.generators import ExtensionsGenerator - - -class ContactFactory(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def create(self, contact_spec): - contact_deref = self.dereferencer.dereference(contact_spec) - name = contact_deref.get('name') - url = contact_deref.get('url') - email = contact_deref.get('email') - - extensions = self.extensions_generator.generate(contact_deref) - - return Contact(name=name, url=url, email=email, extensions=extensions) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/contacts/models.py b/openapi_core/schema/contacts/models.py deleted file mode 100644 index 9e5bb2a7..00000000 --- a/openapi_core/schema/contacts/models.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OpenAPI core contacts models module""" - - -class Contact(object): - - def __init__(self, name=None, url=None, email=None, extensions=None): - self.name = name - self.url = url - self.email = email - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/content/exceptions.py b/openapi_core/schema/content/exceptions.py deleted file mode 100644 index ab29b1b0..00000000 --- a/openapi_core/schema/content/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIContentError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class MimeTypeNotFound(OpenAPIContentError): - mimetype = attr.ib() - availableMimetypes = attr.ib() - - def __str__(self): - return "Mimetype not found: {0}. Valid mimetypes: {1}".format( - self.mimetype, self.availableMimetypes) diff --git a/openapi_core/schema/content/models.py b/openapi_core/schema/content/models.py deleted file mode 100644 index 4af6ed87..00000000 --- a/openapi_core/schema/content/models.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OpenAPI core content models module""" -import fnmatch - -from six import iteritems - -from openapi_core.schema.content.exceptions import MimeTypeNotFound - - -class Content(dict): - - def __getitem__(self, mimetype): - try: - return super(Content, self).__getitem__(mimetype) - except KeyError: - pass - - for key, value in iteritems(self): - if fnmatch.fnmatch(mimetype, key): - return value - - raise MimeTypeNotFound(mimetype, list(self.keys())) diff --git a/openapi_core/schema/encodings.py b/openapi_core/schema/encodings.py new file mode 100644 index 00000000..2dd3d9fa --- /dev/null +++ b/openapi_core/schema/encodings.py @@ -0,0 +1,40 @@ +from typing import Optional +from typing import cast + +from jsonschema_path import SchemaPath + + +def get_content_type( + prop_schema: SchemaPath, encoding: Optional[SchemaPath] +) -> str: + if encoding is None: + return get_default_content_type(prop_schema, encoding=False) + + if "contentType" not in encoding: + return get_default_content_type(prop_schema, encoding=True) + + return cast(str, encoding["contentType"]) + + +def get_default_content_type( + prop_schema: Optional[SchemaPath], encoding: bool = False +) -> str: + if prop_schema is None: + return "text/plain" + + prop_type = prop_schema.getkey("type") + if prop_type is None: + return "text/plain" if encoding else "application/octet-stream" + + prop_format = prop_schema.getkey("format") + if prop_type == "string" and prop_format in ["binary", "base64"]: + return "application/octet-stream" + + if prop_type == "object": + return "application/json" + + if prop_type == "array": + prop_items = prop_schema / "items" + return get_default_content_type(prop_items, encoding=encoding) + + return "text/plain" diff --git a/openapi_core/schema/exceptions.py b/openapi_core/schema/exceptions.py deleted file mode 100644 index 3c1e93d0..00000000 --- a/openapi_core/schema/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OpenAPI core schema exceptions module""" -from openapi_core.exceptions import OpenAPIError - - -class OpenAPIMappingError(OpenAPIError): - pass diff --git a/openapi_core/schema/extensions/generators.py b/openapi_core/schema/extensions/generators.py deleted file mode 100644 index 1cfc459d..00000000 --- a/openapi_core/schema/extensions/generators.py +++ /dev/null @@ -1,16 +0,0 @@ -"""OpenAPI core extensions generators module""" -from six import iteritems - -from openapi_core.schema.extensions.models import Extension - - -class ExtensionsGenerator(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def generate(self, item_spec): - for field_name, value in iteritems(item_spec): - if not field_name.startswith('x-'): - continue - yield field_name, Extension(field_name, value) diff --git a/openapi_core/schema/extensions/models.py b/openapi_core/schema/extensions/models.py deleted file mode 100644 index 5575cfcf..00000000 --- a/openapi_core/schema/extensions/models.py +++ /dev/null @@ -1,9 +0,0 @@ -"""OpenAPI core extensions models module""" - - -class Extension(object): - """Represents an OpenAPI Extension.""" - - def __init__(self, field_name, value=None): - self.field_name = field_name - self.value = value diff --git a/openapi_core/schema/external_docs/factories.py b/openapi_core/schema/external_docs/factories.py deleted file mode 100644 index a1ef10ba..00000000 --- a/openapi_core/schema/external_docs/factories.py +++ /dev/null @@ -1,26 +0,0 @@ -"""OpenAPI core external docs factories module""" -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.external_docs.models import ExternalDocumentation - - -class ExternalDocumentationFactory(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def create(self, external_doc_spec): - url = external_doc_spec['url'] - description = external_doc_spec.get('description') - - extensions = self.extensions_generator.generate(external_doc_spec) - - return ExternalDocumentation( - url, - description=description, extensions=extensions, - ) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/external_docs/models.py b/openapi_core/schema/external_docs/models.py deleted file mode 100644 index b0d0a0e7..00000000 --- a/openapi_core/schema/external_docs/models.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OpenAPI core external docs models module""" - - -class ExternalDocumentation(object): - """Represents an OpenAPI External Documentation.""" - - def __init__(self, url, description=None, extensions=None): - self.url = url - self.description = description - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/infos/factories.py b/openapi_core/schema/infos/factories.py deleted file mode 100644 index 20b8b600..00000000 --- a/openapi_core/schema/infos/factories.py +++ /dev/null @@ -1,52 +0,0 @@ -"""OpenAPI core infos factories module""" -from openapi_core.compat import lru_cache -from openapi_core.schema.contacts.factories import ContactFactory -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.infos.models import Info -from openapi_core.schema.licenses.factories import LicenseFactory - - -class InfoFactory(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def create(self, info_spec): - info_deref = self.dereferencer.dereference(info_spec) - title = info_deref['title'] - version = info_deref['version'] - description = info_deref.get('description') - terms_of_service = info_deref.get('termsOfService') - - extensions = self.extensions_generator.generate(info_deref) - - contact = None - if 'contact' in info_deref: - contact_spec = info_deref.get('contact') - contact = self.contact_factory.create(contact_spec) - - license = None - if 'license' in info_deref: - license_spec = info_deref.get('license') - license = self.license_factory.create(license_spec) - - return Info( - title, version, - description=description, terms_of_service=terms_of_service, - contact=contact, license=license, extensions=extensions, - ) - - @property - @lru_cache() - def contact_factory(self): - return ContactFactory(self.dereferencer) - - @property - @lru_cache() - def license_factory(self): - return LicenseFactory(self.dereferencer) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/infos/models.py b/openapi_core/schema/infos/models.py deleted file mode 100644 index 9ccd66a0..00000000 --- a/openapi_core/schema/infos/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""OpenAPI core infos models module""" - - -class Info(object): - - def __init__( - self, title, version, description=None, terms_of_service=None, - contact=None, license=None, extensions=None, - ): - self.title = title - self.version = version - self.description = description - self.terms_of_service = terms_of_service - self.contact = contact - self.license = license - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/licenses/factories.py b/openapi_core/schema/licenses/factories.py deleted file mode 100644 index 294babe8..00000000 --- a/openapi_core/schema/licenses/factories.py +++ /dev/null @@ -1,24 +0,0 @@ -"""OpenAPI core licenses factories module""" -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.licenses.models import License - - -class LicenseFactory(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def create(self, license_spec): - license_deref = self.dereferencer.dereference(license_spec) - name = license_deref['name'] - url = license_deref.get('url') - - extensions = self.extensions_generator.generate(license_deref) - - return License(name, url=url, extensions=extensions) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/licenses/models.py b/openapi_core/schema/licenses/models.py deleted file mode 100644 index c6dd506d..00000000 --- a/openapi_core/schema/licenses/models.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OpenAPI core licenses models module""" - - -class License(object): - - def __init__(self, name, url=None, extensions=None): - self.name = name - self.url = url - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/links/generators.py b/openapi_core/schema/links/generators.py deleted file mode 100644 index 34add75b..00000000 --- a/openapi_core/schema/links/generators.py +++ /dev/null @@ -1,44 +0,0 @@ -"""OpenAPI core links generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.links.models import Link -from openapi_core.schema.parameters.generators import ParametersGenerator -from openapi_core.schema.servers.generators import ServersGenerator - - -class LinksGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, links): - for link_name, link in iteritems(links): - link_deref = self.dereferencer.dereference(link) - operation_id = link_deref.get('operationId') - parameters = link_deref.get('parameters', {}) - request_body = link_deref.get('requestBody') # string or dict - description = link_deref.get('description') - server_spec = link_deref.get('server') - server = self.servers_generator.generate(server_spec) \ - if server_spec is not None \ - else None - - yield link_name, Link( - operation_id, - parameters, - request_body, - description, - server - ) - - @property - @lru_cache() - def parameters_generator(self): - return ParametersGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def servers_generator(self): - return ServersGenerator(self.dereferencer) diff --git a/openapi_core/schema/links/models.py b/openapi_core/schema/links/models.py deleted file mode 100644 index f62dbac5..00000000 --- a/openapi_core/schema/links/models.py +++ /dev/null @@ -1,26 +0,0 @@ -"""OpenAPI core links models module""" - - -class Link(object): - """Represents an OpenAPI Link.""" - - def __init__( - self, - operation_id, - parameters, - request_body, - description, - server - ): - """ - request_body is assumed to be either a string (JSON, YAML or - runtime expression) or an object (deserialized JSON or YAML) - """ - self.operationId = operation_id - self.description = description - self.server = server - self.parameters = dict(parameters) if parameters else {} - self.request_body = request_body - - def __getitem__(self, item): - return self.parameters[item] diff --git a/openapi_core/schema/media_types/exceptions.py b/openapi_core/schema/media_types/exceptions.py deleted file mode 100644 index a69c3839..00000000 --- a/openapi_core/schema/media_types/exceptions.py +++ /dev/null @@ -1,16 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIMediaTypeError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class InvalidContentType(OpenAPIMediaTypeError): - mimetype = attr.ib() - - def __str__(self): - return "Content for following mimetype not found: {0}".format( - self.mimetype) diff --git a/openapi_core/schema/media_types/generators.py b/openapi_core/schema/media_types/generators.py deleted file mode 100644 index dcaff53f..00000000 --- a/openapi_core/schema/media_types/generators.py +++ /dev/null @@ -1,40 +0,0 @@ -"""OpenAPI core media types generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.media_types.models import MediaType - - -class MediaTypeGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, content): - for mimetype, media_type in iteritems(content): - schema_spec = media_type.get('schema') - - example_spec = media_type.get('example') - example_type = type(example_spec) - if example_type is dict: - example = self.dereferencer.dereference(example_spec) - else: - example = example_spec - - extensions = self.extensions_generator.generate(media_type) - - schema = None - if schema_spec: - schema, _ = self.schemas_registry.get_or_create(schema_spec) - - yield mimetype, MediaType( - mimetype, - schema=schema, example=example, extensions=extensions, - ) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/media_types/models.py b/openapi_core/schema/media_types/models.py deleted file mode 100644 index 76079f73..00000000 --- a/openapi_core/schema/media_types/models.py +++ /dev/null @@ -1,12 +0,0 @@ -"""OpenAPI core media types models module""" - - -class MediaType(object): - """Represents an OpenAPI MediaType.""" - - def __init__(self, mimetype, schema=None, example=None, extensions=None): - self.mimetype = mimetype - self.schema = schema - self.example = example - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/operations/exceptions.py b/openapi_core/schema/operations/exceptions.py deleted file mode 100644 index e78708aa..00000000 --- a/openapi_core/schema/operations/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIOperationError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class InvalidOperation(OpenAPIOperationError): - path_pattern = attr.ib() - http_method = attr.ib() - - def __str__(self): - return "Unknown operation path {0} with method {1}".format( - self.path_pattern, self.http_method) diff --git a/openapi_core/schema/operations/generators.py b/openapi_core/schema/operations/generators.py deleted file mode 100644 index d30166c2..00000000 --- a/openapi_core/schema/operations/generators.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -"""OpenAPI core operations models module""" -from six import iteritems -from openapi_spec_validator.validators import PathItemValidator - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.external_docs.factories import ( - ExternalDocumentationFactory, -) -from openapi_core.schema.operations.models import Operation -from openapi_core.schema.parameters.generators import ParametersGenerator -from openapi_core.schema.request_bodies.factories import RequestBodyFactory -from openapi_core.schema.responses.generators import ResponsesGenerator -from openapi_core.schema.security_requirements.generators import ( - SecurityRequirementsGenerator, -) -from openapi_core.schema.servers.generators import ServersGenerator - - -class OperationsGenerator(object): - """Represents an OpenAPI Operation in a service.""" - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, path_name, path): - path_deref = self.dereferencer.dereference(path) - for http_method, operation in iteritems(path_deref): - if http_method not in PathItemValidator.OPERATIONS: - continue - - operation_deref = self.dereferencer.dereference(operation) - responses_spec = operation_deref['responses'] - responses = self.responses_generator.generate(responses_spec) - deprecated = operation_deref.get('deprecated', False) - parameters_list = operation_deref.get('parameters', []) - parameters = self.parameters_generator.generate_from_list( - parameters_list) - operation_id = operation_deref.get('operationId') - tags_list = operation_deref.get('tags', []) - summary = operation_deref.get('summary') - description = operation_deref.get('description') - servers_spec = operation_deref.get('servers', []) - - servers = self.servers_generator.generate(servers_spec) - - security = None - if 'security' in operation_deref: - security_spec = operation_deref.get('security') - security = self.security_requirements_generator.generate( - security_spec) - - extensions = self.extensions_generator.generate(operation_deref) - - external_docs = None - if 'externalDocs' in operation_deref: - external_docs_spec = operation_deref.get('externalDocs') - external_docs = self.external_docs_factory.create( - external_docs_spec) - - request_body = None - if 'requestBody' in operation_deref: - request_body_spec = operation_deref.get('requestBody') - request_body = self.request_body_factory.create( - request_body_spec) - - yield ( - http_method, - Operation( - http_method, path_name, responses, list(parameters), - summary=summary, description=description, - external_docs=external_docs, security=security, - request_body=request_body, deprecated=deprecated, - operation_id=operation_id, tags=list(tags_list), - servers=list(servers), extensions=extensions, - ), - ) - - @property - @lru_cache() - def responses_generator(self): - return ResponsesGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def parameters_generator(self): - return ParametersGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def external_docs_factory(self): - return ExternalDocumentationFactory(self.dereferencer) - - @property - @lru_cache() - def request_body_factory(self): - return RequestBodyFactory(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def security_requirements_generator(self): - return SecurityRequirementsGenerator(self.dereferencer) - - @property - @lru_cache() - def servers_generator(self): - return ServersGenerator(self.dereferencer) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/operations/models.py b/openapi_core/schema/operations/models.py deleted file mode 100644 index f2acaa1c..00000000 --- a/openapi_core/schema/operations/models.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -"""OpenAPI core operations models module""" -from openapi_core.schema.responses.exceptions import InvalidResponse - - -class Operation(object): - """Represents an OpenAPI Operation.""" - - def __init__( - self, http_method, path_name, responses, parameters, - summary=None, description=None, external_docs=None, security=None, - request_body=None, deprecated=False, operation_id=None, tags=None, - servers=None, extensions=None): - self.http_method = http_method - self.path_name = path_name - self.responses = dict(responses) - self.parameters = dict(parameters) - self.summary = summary - self.description = description - self.external_docs = external_docs - self.security = security and list(security) - self.request_body = request_body - self.deprecated = deprecated - self.operation_id = operation_id - self.tags = tags - self.servers = servers - - self.extensions = extensions and dict(extensions) or {} - - def __getitem__(self, name): - return self.parameters[name] - - def get_response(self, http_status='default'): - # @todo: move to Responses object - try: - return self.responses[http_status] - except KeyError: - # try range - http_status_range = '{0}XX'.format(http_status[0]) - if http_status_range in self.responses: - return self.responses[http_status_range] - - if 'default' not in self.responses: - raise InvalidResponse(http_status, self.responses) - - return self.responses['default'] diff --git a/openapi_core/schema/parameters.py b/openapi_core/schema/parameters.py new file mode 100644 index 00000000..967e53f3 --- /dev/null +++ b/openapi_core/schema/parameters.py @@ -0,0 +1,40 @@ +from typing import Tuple + +from jsonschema_path import SchemaPath + + +def get_style( + param_or_header: SchemaPath, default_location: str = "header" +) -> str: + """Checks parameter/header style for simpler scenarios""" + if "style" in param_or_header: + assert isinstance(param_or_header["style"], str) + return param_or_header["style"] + + location = param_or_header.getkey("in", default_location) + + # determine default + return "simple" if location in ["path", "header"] else "form" + + +def get_explode(param_or_header: SchemaPath) -> bool: + """Checks parameter/header explode for simpler scenarios""" + if "explode" in param_or_header: + assert isinstance(param_or_header["explode"], bool) + return param_or_header["explode"] + + # determine default + style = get_style(param_or_header) + return style == "form" + + +def get_style_and_explode( + param_or_header: SchemaPath, default_location: str = "header" +) -> Tuple[str, bool]: + """Checks parameter/header explode for simpler scenarios""" + style = get_style(param_or_header, default_location=default_location) + if "explode" in param_or_header: + assert isinstance(param_or_header["explode"], bool) + return style, param_or_header["explode"] + + return style, style == "form" diff --git a/openapi_core/schema/parameters/enums.py b/openapi_core/schema/parameters/enums.py deleted file mode 100644 index 51fa238f..00000000 --- a/openapi_core/schema/parameters/enums.py +++ /dev/null @@ -1,25 +0,0 @@ -"""OpenAPI core parameters enums module""" -from enum import Enum - - -class ParameterLocation(Enum): - - PATH = 'path' - QUERY = 'query' - HEADER = 'header' - COOKIE = 'cookie' - - @classmethod - def has_value(cls, value): - return (any(value == item.value for item in cls)) - - -class ParameterStyle(Enum): - - MATRIX = 'matrix' - LABEL = 'label' - FORM = 'form' - SIMPLE = 'simple' - SPACE_DELIMITED = 'spaceDelimited' - PIPE_DELIMITED = 'pipeDelimited' - DEEP_OBJECT = 'deepObject' diff --git a/openapi_core/schema/parameters/exceptions.py b/openapi_core/schema/parameters/exceptions.py deleted file mode 100644 index e9d7c26d..00000000 --- a/openapi_core/schema/parameters/exceptions.py +++ /dev/null @@ -1,29 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIParameterError(OpenAPIMappingError): - pass - - -class MissingParameterError(OpenAPIParameterError): - """Missing parameter error""" - pass - - -@attr.s(hash=True) -class MissingParameter(MissingParameterError): - name = attr.ib() - - def __str__(self): - return "Missing parameter (without default value): {0}".format( - self.name) - - -@attr.s(hash=True) -class MissingRequiredParameter(MissingParameterError): - name = attr.ib() - - def __str__(self): - return "Missing required parameter: {0}".format(self.name) diff --git a/openapi_core/schema/parameters/factories.py b/openapi_core/schema/parameters/factories.py deleted file mode 100644 index 2e13cebc..00000000 --- a/openapi_core/schema/parameters/factories.py +++ /dev/null @@ -1,33 +0,0 @@ -"""OpenAPI core parameters factories module""" -from openapi_core.schema.parameters.models import Parameter - - -class ParameterFactory(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def create(self, parameter_spec, parameter_name=None): - parameter_deref = self.dereferencer.dereference(parameter_spec) - - parameter_name = parameter_name or parameter_deref['name'] - parameter_in = parameter_deref.get('in', 'header') - - allow_empty_value = parameter_deref.get('allowEmptyValue') - required = parameter_deref.get('required', False) - - style = parameter_deref.get('style') - explode = parameter_deref.get('explode') - - schema_spec = parameter_deref.get('schema', None) - schema = None - if schema_spec: - schema, _ = self.schemas_registry.get_or_create(schema_spec) - - return Parameter( - parameter_name, parameter_in, - schema=schema, required=required, - allow_empty_value=allow_empty_value, - style=style, explode=explode, - ) diff --git a/openapi_core/schema/parameters/generators.py b/openapi_core/schema/parameters/generators.py deleted file mode 100644 index 0f5d78e6..00000000 --- a/openapi_core/schema/parameters/generators.py +++ /dev/null @@ -1,30 +0,0 @@ -"""OpenAPI core parameters generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.parameters.factories import ParameterFactory - - -class ParametersGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, parameters): - for parameter_name, parameter_spec in iteritems(parameters): - parameter = self.parameter_factory.create( - parameter_spec, parameter_name=parameter_name) - - yield (parameter_name, parameter) - - def generate_from_list(self, parameters_list): - for parameter_spec in parameters_list: - parameter = self.parameter_factory.create(parameter_spec) - - yield (parameter.name, parameter) - - @property - @lru_cache() - def parameter_factory(self): - return ParameterFactory(self.dereferencer, self.schemas_registry) diff --git a/openapi_core/schema/parameters/models.py b/openapi_core/schema/parameters/models.py deleted file mode 100644 index e99194ff..00000000 --- a/openapi_core/schema/parameters/models.py +++ /dev/null @@ -1,50 +0,0 @@ -"""OpenAPI core parameters models module""" -import logging - -from openapi_core.schema.parameters.enums import ( - ParameterLocation, ParameterStyle, -) -from openapi_core.schema.schemas.enums import SchemaType - -log = logging.getLogger(__name__) - - -class Parameter(object): - """Represents an OpenAPI operation Parameter.""" - - def __init__( - self, name, location, schema=None, required=False, - deprecated=False, allow_empty_value=False, - items=None, style=None, explode=None): - self.name = name - self.location = ParameterLocation(location) - self.schema = schema - self.required = ( - True if self.location == ParameterLocation.PATH else required - ) - self.deprecated = deprecated - self.allow_empty_value = ( - allow_empty_value if self.location == ParameterLocation.QUERY - else False - ) - self.items = items - self.style = ParameterStyle(style or self.default_style) - self.explode = self.default_explode if explode is None else explode - - @property - def aslist(self): - return ( - self.schema and - self.schema.type in [SchemaType.ARRAY, SchemaType.OBJECT] - ) - - @property - def default_style(self): - simple_locations = [ParameterLocation.PATH, ParameterLocation.HEADER] - return ( - 'simple' if self.location in simple_locations else "form" - ) - - @property - def default_explode(self): - return self.style == ParameterStyle.FORM diff --git a/openapi_core/schema/paths/exceptions.py b/openapi_core/schema/paths/exceptions.py deleted file mode 100644 index 6a287739..00000000 --- a/openapi_core/schema/paths/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIPathError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class InvalidPath(OpenAPIPathError): - path_pattern = attr.ib() - - def __str__(self): - return "Unknown path {0}".format(self.path_pattern) diff --git a/openapi_core/schema/paths/generators.py b/openapi_core/schema/paths/generators.py deleted file mode 100644 index 82809f2d..00000000 --- a/openapi_core/schema/paths/generators.py +++ /dev/null @@ -1,62 +0,0 @@ -"""OpenAPI core paths generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.operations.generators import OperationsGenerator -from openapi_core.schema.parameters.generators import ParametersGenerator -from openapi_core.schema.paths.models import Path -from openapi_core.schema.servers.generators import ServersGenerator - - -class PathsGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, paths): - paths_deref = self.dereferencer.dereference(paths) - for path_name, path_spec in iteritems(paths_deref): - path_deref = self.dereferencer.dereference(path_spec) - - parameters_list = path_deref.get('parameters', []) - summary = path_deref.get('summary') - description = path_deref.get('description') - servers_spec = path_deref.get('servers', []) - - operations = self.operations_generator.generate( - path_name, path_deref) - servers = self.servers_generator.generate(servers_spec) - parameters = self.parameters_generator.generate_from_list( - parameters_list) - extensions = self.extensions_generator.generate(path_deref) - - yield ( - path_name, - Path( - path_name, list(operations), parameters=list(parameters), - summary=summary, description=description, - servers=list(servers), extensions=extensions, - ), - ) - - @property - @lru_cache() - def operations_generator(self): - return OperationsGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def servers_generator(self): - return ServersGenerator(self.dereferencer) - - @property - @lru_cache() - def parameters_generator(self): - return ParametersGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/paths/models.py b/openapi_core/schema/paths/models.py deleted file mode 100644 index 74a57a92..00000000 --- a/openapi_core/schema/paths/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""OpenAPI core paths models module""" - - -class Path(object): - """Represents an OpenAPI Path.""" - - def __init__( - self, name, operations, - summary=None, description=None, parameters=None, servers=None, - extensions=None, - ): - self.name = name - self.operations = dict(operations) - self.summary = summary - self.description = description - self.servers = servers - self.parameters = dict(parameters) if parameters else {} - - self.extensions = extensions and dict(extensions) or {} - - def __getitem__(self, http_method): - return self.operations[http_method] diff --git a/openapi_core/schema/properties/generators.py b/openapi_core/schema/properties/generators.py deleted file mode 100644 index b0485b08..00000000 --- a/openapi_core/schema/properties/generators.py +++ /dev/null @@ -1,18 +0,0 @@ -"""OpenAPI core properties generators module""" -from six import iteritems - - -class PropertiesGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, properties): - for property_name, schema_spec in iteritems(properties): - schema = self._create_schema(schema_spec) - yield property_name, schema - - def _create_schema(self, schema_spec): - schema, _ = self.schemas_registry.get_or_create(schema_spec) - return schema diff --git a/openapi_core/schema/protocols.py b/openapi_core/schema/protocols.py new file mode 100644 index 00000000..72ee2e31 --- /dev/null +++ b/openapi_core/schema/protocols.py @@ -0,0 +1,14 @@ +from typing import Any +from typing import List +from typing import Protocol +from typing import runtime_checkable + + +@runtime_checkable +class SuportsGetAll(Protocol): + def getall(self, name: str) -> List[Any]: ... + + +@runtime_checkable +class SuportsGetList(Protocol): + def getlist(self, name: str) -> List[Any]: ... diff --git a/openapi_core/schema/request_bodies/exceptions.py b/openapi_core/schema/request_bodies/exceptions.py deleted file mode 100644 index a84937b6..00000000 --- a/openapi_core/schema/request_bodies/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIRequestBodyError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class MissingRequestBody(OpenAPIRequestBodyError): - request = attr.ib() - - def __str__(self): - return "Missing required request body" diff --git a/openapi_core/schema/request_bodies/factories.py b/openapi_core/schema/request_bodies/factories.py deleted file mode 100644 index a8a756e4..00000000 --- a/openapi_core/schema/request_bodies/factories.py +++ /dev/null @@ -1,36 +0,0 @@ -"""OpenAPI core request bodies factories module""" -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.media_types.generators import MediaTypeGenerator -from openapi_core.schema.request_bodies.models import RequestBody - - -class RequestBodyFactory(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def create(self, request_body_spec): - request_body_deref = self.dereferencer.dereference( - request_body_spec) - content = request_body_deref['content'] - media_types = self.media_types_generator.generate(content) - required = request_body_deref.get('required', False) - - extensions = self.extensions_generator.generate(request_body_deref) - - return RequestBody( - media_types, - required=required, extensions=extensions, - ) - - @property - @lru_cache() - def media_types_generator(self): - return MediaTypeGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/request_bodies/models.py b/openapi_core/schema/request_bodies/models.py deleted file mode 100644 index 925112d5..00000000 --- a/openapi_core/schema/request_bodies/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""OpenAPI core request bodies models module""" -from openapi_core.schema.content.exceptions import MimeTypeNotFound -from openapi_core.schema.content.models import Content -from openapi_core.schema.media_types.exceptions import InvalidContentType - - -class RequestBody(object): - """Represents an OpenAPI RequestBody.""" - - def __init__(self, content, required=False, extensions=None): - self.content = Content(content) - self.required = required - - self.extensions = extensions and dict(extensions) or {} - - def __getitem__(self, mimetype): - try: - return self.content[mimetype] - except MimeTypeNotFound: - raise InvalidContentType(mimetype) diff --git a/openapi_core/schema/responses/exceptions.py b/openapi_core/schema/responses/exceptions.py deleted file mode 100644 index 577ad83d..00000000 --- a/openapi_core/schema/responses/exceptions.py +++ /dev/null @@ -1,25 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIResponseError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class InvalidResponse(OpenAPIResponseError): - http_status = attr.ib() - responses = attr.ib() - - def __str__(self): - return "Unknown response http status: {0}".format( - str(self.http_status)) - - -@attr.s(hash=True) -class MissingResponseContent(OpenAPIResponseError): - response = attr.ib() - - def __str__(self): - return "Missing response content" diff --git a/openapi_core/schema/responses/generators.py b/openapi_core/schema/responses/generators.py deleted file mode 100644 index f74e1527..00000000 --- a/openapi_core/schema/responses/generators.py +++ /dev/null @@ -1,61 +0,0 @@ -"""OpenAPI core responses generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.links.generators import LinksGenerator -from openapi_core.schema.media_types.generators import MediaTypeGenerator -from openapi_core.schema.parameters.generators import ParametersGenerator -from openapi_core.schema.responses.models import Response - - -class ResponsesGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, responses): - for http_status, response in iteritems(responses): - response_deref = self.dereferencer.dereference(response) - description = response_deref['description'] - headers = response_deref.get('headers') - content = response_deref.get('content') - links_dict = response_deref.get('links', {}) - links = self.links_generator.generate(links_dict) - - extensions = self.extensions_generator.generate(response_deref) - - media_types = None - if content: - media_types = self.media_types_generator.generate(content) - - parameters = None - if headers: - parameters = self.parameters_generator.generate(headers) - - yield http_status, Response( - http_status, description, - content=media_types, headers=parameters, links=links, - extensions=extensions, - ) - - @property - @lru_cache() - def media_types_generator(self): - return MediaTypeGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def parameters_generator(self): - return ParametersGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def links_generator(self): - return LinksGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/responses/models.py b/openapi_core/schema/responses/models.py deleted file mode 100644 index 21dffc5f..00000000 --- a/openapi_core/schema/responses/models.py +++ /dev/null @@ -1,27 +0,0 @@ -"""OpenAPI core responses models module""" -from openapi_core.schema.content.exceptions import MimeTypeNotFound -from openapi_core.schema.content.models import Content -from openapi_core.schema.media_types.exceptions import InvalidContentType - - -class Response(object): - - def __init__( - self, http_status, description, headers=None, content=None, - links=None, extensions=None): - self.http_status = http_status - self.description = description - self.headers = headers and dict(headers) or {} - self.content = content and Content(content) or Content() - self.links = links and dict(links) or {} - - self.extensions = extensions and dict(extensions) or {} - - def __getitem__(self, mimetype): - return self.get_content_type(mimetype) - - def get_content_type(self, mimetype): - try: - return self.content[mimetype] - except MimeTypeNotFound: - raise InvalidContentType(mimetype) diff --git a/openapi_core/schema/schemas.py b/openapi_core/schema/schemas.py new file mode 100644 index 00000000..7ddb6b17 --- /dev/null +++ b/openapi_core/schema/schemas.py @@ -0,0 +1,10 @@ +from typing import Any +from typing import Dict + +from jsonschema_path import SchemaPath + + +def get_properties(schema: SchemaPath) -> Dict[str, Any]: + properties = schema.get("properties", {}) + properties_dict = dict(list(properties.items())) + return properties_dict diff --git a/openapi_core/schema/schemas/enums.py b/openapi_core/schema/schemas/enums.py deleted file mode 100644 index 8b77e7ca..00000000 --- a/openapi_core/schema/schemas/enums.py +++ /dev/null @@ -1,28 +0,0 @@ -"""OpenAPI core schemas enums module""" -from enum import Enum - - -class SchemaType(Enum): - - ANY = None - INTEGER = 'integer' - NUMBER = 'number' - STRING = 'string' - BOOLEAN = 'boolean' - ARRAY = 'array' - OBJECT = 'object' - - -class SchemaFormat(Enum): - - NONE = None - INT32 = 'int32' - INT64 = 'int64' - FLOAT = 'float' - DOUBLE = 'double' - BYTE = 'byte' - BINARY = 'binary' - DATE = 'date' - DATETIME = 'date-time' - PASSWORD = 'password' - UUID = 'uuid' diff --git a/openapi_core/schema/schemas/exceptions.py b/openapi_core/schema/schemas/exceptions.py deleted file mode 100644 index 1487bf7c..00000000 --- a/openapi_core/schema/schemas/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPISchemaError(OpenAPIMappingError): - pass diff --git a/openapi_core/schema/schemas/factories.py b/openapi_core/schema/schemas/factories.py deleted file mode 100644 index 55b48fe1..00000000 --- a/openapi_core/schema/schemas/factories.py +++ /dev/null @@ -1,175 +0,0 @@ -"""OpenAPI core schemas factories module""" -import logging - -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.properties.generators import PropertiesGenerator -from openapi_core.schema.schemas.models import Schema -from openapi_core.schema.schemas.types import Contribution, NoValue - -log = logging.getLogger(__name__) - - -class SchemaFactory(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def create(self, schema_spec): - schema_deref = self.dereferencer.dereference(schema_spec) - - schema_type = schema_deref.get('type', None) - schema_format = schema_deref.get('format') - required = schema_deref.get('required', False) - default = schema_deref.get('default', NoValue) - properties_spec = schema_deref.get('properties', None) - items_spec = schema_deref.get('items', None) - nullable = schema_deref.get('nullable', False) - enum = schema_deref.get('enum', None) - deprecated = schema_deref.get('deprecated', False) - all_of_spec = schema_deref.get('allOf', None) - one_of_spec = schema_deref.get('oneOf', None) - additional_properties_spec = schema_deref.get('additionalProperties', - True) - min_items = schema_deref.get('minItems', None) - max_items = schema_deref.get('maxItems', None) - min_length = schema_deref.get('minLength', None) - max_length = schema_deref.get('maxLength', None) - pattern = schema_deref.get('pattern', None) - unique_items = schema_deref.get('uniqueItems', None) - minimum = schema_deref.get('minimum', None) - maximum = schema_deref.get('maximum', None) - multiple_of = schema_deref.get('multipleOf', None) - exclusive_minimum = schema_deref.get('exclusiveMinimum', False) - exclusive_maximum = schema_deref.get('exclusiveMaximum', False) - min_properties = schema_deref.get('minProperties', None) - max_properties = schema_deref.get('maxProperties', None) - read_only = schema_deref.get('readOnly', False) - write_only = schema_deref.get('writeOnly', False) - - extensions = self.extensions_generator.generate(schema_deref) - - properties = None - if properties_spec: - properties = self.properties_generator.generate(properties_spec) - - all_of = [] - if all_of_spec: - all_of = list(map(self.create, all_of_spec)) - - one_of = [] - if one_of_spec: - one_of = list(map(self.create, one_of_spec)) - - items = None - if items_spec: - items = self._create_items(items_spec) - - additional_properties = additional_properties_spec - if isinstance(additional_properties_spec, dict): - additional_properties = self.create(additional_properties_spec) - - return Schema( - schema_type=schema_type, properties=properties, - items=items, schema_format=schema_format, required=required, - default=default, nullable=nullable, enum=enum, - deprecated=deprecated, all_of=all_of, one_of=one_of, - additional_properties=additional_properties, - min_items=min_items, max_items=max_items, min_length=min_length, - max_length=max_length, pattern=pattern, unique_items=unique_items, - minimum=minimum, maximum=maximum, multiple_of=multiple_of, - exclusive_maximum=exclusive_maximum, - exclusive_minimum=exclusive_minimum, - min_properties=min_properties, max_properties=max_properties, - read_only=read_only, write_only=write_only, extensions=extensions, - _source=schema_deref, - ) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) - - @property - @lru_cache() - def properties_generator(self): - return PropertiesGenerator(self.dereferencer, self) - - def _create_items(self, items_spec): - return self.create(items_spec) - - -class SchemaDictFactory(object): - - contributions = ( - Contribution('type', src_prop_attr='value'), - Contribution('format'), - Contribution('properties', is_dict=True, dest_default={}), - Contribution('required', dest_default=[]), - Contribution('default'), - Contribution('nullable', dest_default=False), - Contribution( - 'all_of', - dest_prop_name='allOf', is_list=True, dest_default=[], - ), - Contribution( - 'one_of', - dest_prop_name='oneOf', is_list=True, dest_default=[], - ), - Contribution( - 'additional_properties', - dest_prop_name='additionalProperties', dest_default=True, - ), - Contribution('min_items', dest_prop_name='minItems'), - Contribution('max_items', dest_prop_name='maxItems'), - Contribution('min_length', dest_prop_name='minLength'), - Contribution('max_length', dest_prop_name='maxLength'), - Contribution('pattern', src_prop_attr='pattern'), - Contribution( - 'unique_items', - dest_prop_name='uniqueItems', dest_default=False, - ), - Contribution('minimum'), - Contribution('maximum'), - Contribution('multiple_of', dest_prop_name='multipleOf'), - Contribution( - 'exclusive_minimum', - dest_prop_name='exclusiveMinimum', dest_default=False, - ), - Contribution( - 'exclusive_maximum', - dest_prop_name='exclusiveMaximum', dest_default=False, - ), - Contribution('min_properties', dest_prop_name='minProperties'), - Contribution('max_properties', dest_prop_name='maxProperties'), - ) - - def create(self, schema): - schema_dict = {} - for contrib in self.contributions: - self._contribute(schema, schema_dict, contrib) - return schema_dict - - def _contribute(self, schema, schema_dict, contrib): - def src_map(x): - return getattr(x, '__dict__') - src_val = getattr(schema, contrib.src_prop_name) - - if src_val and contrib.src_prop_attr: - src_val = getattr(src_val, contrib.src_prop_attr) - - if contrib.is_list: - src_val = list(map(src_map, src_val)) - if contrib.is_dict: - src_val = dict( - (k, src_map(v)) - for k, v in iteritems(src_val) - ) - - if src_val == contrib.dest_default: - return - - dest_prop_name = contrib.dest_prop_name or contrib.src_prop_name - schema_dict[dest_prop_name] = src_val diff --git a/openapi_core/schema/schemas/generators.py b/openapi_core/schema/schemas/generators.py deleted file mode 100644 index 59fd548b..00000000 --- a/openapi_core/schema/schemas/generators.py +++ /dev/null @@ -1,20 +0,0 @@ -"""OpenAPI core schemas generators module""" -import logging - -from six import iteritems - -log = logging.getLogger(__name__) - - -class SchemasGenerator(object): - - def __init__(self, dereferencer, schemas_registry): - self.dereferencer = dereferencer - self.schemas_registry = schemas_registry - - def generate(self, schemas_spec): - schemas_deref = self.dereferencer.dereference(schemas_spec) - - for schema_name, schema_spec in iteritems(schemas_deref): - schema, _ = self.schemas_registry.get_or_create(schema_spec) - yield schema_name, schema diff --git a/openapi_core/schema/schemas/models.py b/openapi_core/schema/schemas/models.py deleted file mode 100644 index a4109c4d..00000000 --- a/openapi_core/schema/schemas/models.py +++ /dev/null @@ -1,95 +0,0 @@ -"""OpenAPI core schemas models module""" -import attr -import logging -import re - -from openapi_core.schema.schemas.enums import SchemaType -from openapi_core.schema.schemas.types import NoValue - -log = logging.getLogger(__name__) - - -@attr.s -class Format(object): - unmarshal = attr.ib() - validate = attr.ib() - - -class Schema(object): - """Represents an OpenAPI Schema.""" - - def __init__( - self, schema_type=None, properties=None, items=None, - schema_format=None, required=None, default=NoValue, nullable=False, - enum=None, deprecated=False, all_of=None, one_of=None, - additional_properties=True, min_items=None, max_items=None, - min_length=None, max_length=None, pattern=None, unique_items=False, - minimum=None, maximum=None, multiple_of=None, - exclusive_minimum=False, exclusive_maximum=False, - min_properties=None, max_properties=None, - read_only=False, write_only=False, extensions=None, - _source=None): - self.type = SchemaType(schema_type) - self.properties = properties and dict(properties) or {} - self.items = items - self.format = schema_format - self.required = required or [] - self.default = default - self.nullable = nullable - self.enum = enum - self.deprecated = deprecated - self.all_of = all_of and list(all_of) or [] - self.one_of = one_of and list(one_of) or [] - self.additional_properties = additional_properties - self.min_items = int(min_items) if min_items is not None else None - self.max_items = int(max_items) if max_items is not None else None - self.min_length = int(min_length) if min_length is not None else None - self.max_length = int(max_length) if max_length is not None else None - self.pattern = pattern and re.compile(pattern) or None - self.unique_items = unique_items - self.minimum = int(minimum) if minimum is not None else None - self.maximum = int(maximum) if maximum is not None else None - self.multiple_of = int(multiple_of)\ - if multiple_of is not None else None - self.exclusive_minimum = exclusive_minimum - self.exclusive_maximum = exclusive_maximum - self.min_properties = int(min_properties)\ - if min_properties is not None else None - self.max_properties = int(max_properties)\ - if max_properties is not None else None - self.read_only = read_only - self.write_only = write_only - - self.extensions = extensions and dict(extensions) or {} - - self._all_required_properties_cache = None - self._all_optional_properties_cache = None - - self._source = _source - - @property - def __dict__(self): - return self._source or self.to_dict() - - def to_dict(self): - from openapi_core.schema.schemas.factories import SchemaDictFactory - return SchemaDictFactory().create(self) - - def __getitem__(self, name): - return self.properties[name] - - def has_default(self): - return self.default is not NoValue - - def get_all_properties(self): - properties = self.properties.copy() - - for subschema in self.all_of: - subschema_props = subschema.get_all_properties() - properties.update(subschema_props) - - return properties - - def get_all_properties_names(self): - all_properties = self.get_all_properties() - return set(all_properties.keys()) diff --git a/openapi_core/schema/schemas/registries.py b/openapi_core/schema/schemas/registries.py deleted file mode 100644 index 3a6d963e..00000000 --- a/openapi_core/schema/schemas/registries.py +++ /dev/null @@ -1,32 +0,0 @@ -"""OpenAPI core schemas registries module""" -import logging - -from lazy_object_proxy import Proxy - -from openapi_core.schema.schemas.factories import SchemaFactory -from openapi_core.schema.schemas.util import dicthash - -log = logging.getLogger(__name__) - - -class SchemaRegistry(SchemaFactory): - - def __init__(self, dereferencer): - super(SchemaRegistry, self).__init__(dereferencer) - self._schemas = {} - - def get_or_create(self, schema_spec): - schema_hash = dicthash(schema_spec) - schema_deref = self.dereferencer.dereference(schema_spec) - - if schema_hash in self._schemas: - return self._schemas[schema_hash], False - - if '$ref' in schema_spec: - schema = Proxy(lambda: self.create(schema_deref)) - else: - schema = self.create(schema_deref) - - self._schemas[schema_hash] = schema - - return schema, True diff --git a/openapi_core/schema/schemas/types.py b/openapi_core/schema/schemas/types.py deleted file mode 100644 index 56f74452..00000000 --- a/openapi_core/schema/schemas/types.py +++ /dev/null @@ -1,14 +0,0 @@ -import attr - - -NoValue = object() - - -@attr.s(hash=True) -class Contribution(object): - src_prop_name = attr.ib() - src_prop_attr = attr.ib(default=None) - dest_prop_name = attr.ib(default=None) - is_list = attr.ib(default=False) - is_dict = attr.ib(default=False) - dest_default = attr.ib(default=None) diff --git a/openapi_core/schema/schemas/util.py b/openapi_core/schema/schemas/util.py deleted file mode 100644 index dcbf2fd9..00000000 --- a/openapi_core/schema/schemas/util.py +++ /dev/null @@ -1,15 +0,0 @@ -"""OpenAPI core schemas util module""" -from distutils.util import strtobool -from six import string_types -from json import dumps - - -def forcebool(val): - if isinstance(val, string_types): - val = strtobool(val) - - return bool(val) - - -def dicthash(d): - return hash(dumps(d, sort_keys=True)) diff --git a/openapi_core/schema/security_requirements/generators.py b/openapi_core/schema/security_requirements/generators.py deleted file mode 100644 index 7ccb8ea1..00000000 --- a/openapi_core/schema/security_requirements/generators.py +++ /dev/null @@ -1,15 +0,0 @@ -"""OpenAPI core security requirements generators module""" -from openapi_core.schema.security_requirements.models import ( - SecurityRequirement, -) - - -class SecurityRequirementsGenerator(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def generate(self, security_spec): - security_deref = self.dereferencer.dereference(security_spec) - for security_requirement_spec in security_deref: - yield SecurityRequirement(security_requirement_spec) diff --git a/openapi_core/schema/security_requirements/models.py b/openapi_core/schema/security_requirements/models.py deleted file mode 100644 index b99f070a..00000000 --- a/openapi_core/schema/security_requirements/models.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OpenAPI core security requirements models module""" - - -class SecurityRequirement(dict): - """Represents an OpenAPI Security Requirement.""" - pass diff --git a/openapi_core/schema/security_schemes/enums.py b/openapi_core/schema/security_schemes/enums.py deleted file mode 100644 index b849c3c2..00000000 --- a/openapi_core/schema/security_schemes/enums.py +++ /dev/null @@ -1,27 +0,0 @@ -"""OpenAPI core security schemes enums module""" -from enum import Enum - - -class SecuritySchemeType(Enum): - - API_KEY = 'apiKey' - HTTP = 'http' - OAUTH2 = 'oauth2' - OPEN_ID_CONNECT = 'openIdConnect' - - -class ApiKeyLocation(Enum): - - QUERY = 'query' - HEADER = 'header' - COOKIE = 'cookie' - - @classmethod - def has_value(cls, value): - return (any(value == item.value for item in cls)) - - -class HttpAuthScheme(Enum): - - BASIC = 'basic' - BEARER = 'bearer' diff --git a/openapi_core/schema/security_schemes/generators.py b/openapi_core/schema/security_schemes/generators.py deleted file mode 100644 index 62dc4891..00000000 --- a/openapi_core/schema/security_schemes/generators.py +++ /dev/null @@ -1,37 +0,0 @@ -"""OpenAPI core security schemes generators module""" -import logging - -from six import iteritems - -from openapi_core.schema.security_schemes.models import SecurityScheme - -log = logging.getLogger(__name__) - - -class SecuritySchemesGenerator(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def generate(self, security_schemes_spec): - security_schemes_deref = self.dereferencer.dereference( - security_schemes_spec) - - for scheme_name, scheme_spec in iteritems(security_schemes_deref): - scheme_deref = self.dereferencer.dereference(scheme_spec) - scheme_type = scheme_deref['type'] - description = scheme_deref.get('description') - name = scheme_deref.get('name') - apikey_in = scheme_deref.get('in') - scheme = scheme_deref.get('scheme') - bearer_format = scheme_deref.get('bearerFormat') - flows = scheme_deref.get('flows') - open_id_connect_url = scheme_deref.get('openIdConnectUrl') - - scheme = SecurityScheme( - scheme_type, description=description, name=name, - apikey_in=apikey_in, scheme=scheme, - bearer_format=bearer_format, flows=flows, - open_id_connect_url=open_id_connect_url, - ) - yield scheme_name, scheme diff --git a/openapi_core/schema/security_schemes/models.py b/openapi_core/schema/security_schemes/models.py deleted file mode 100644 index d50f0b1b..00000000 --- a/openapi_core/schema/security_schemes/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""OpenAPI core security schemes models module""" -from openapi_core.schema.security_schemes.enums import ( - SecuritySchemeType, ApiKeyLocation, HttpAuthScheme, -) - - -class SecurityScheme(object): - """Represents an OpenAPI Security Scheme.""" - - def __init__( - self, scheme_type, description=None, name=None, apikey_in=None, - scheme=None, bearer_format=None, flows=None, - open_id_connect_url=None, - ): - self.type = SecuritySchemeType(scheme_type) - self.description = description - self.name = name - self.apikey_in = apikey_in and ApiKeyLocation(apikey_in) - self.scheme = scheme and HttpAuthScheme(scheme) - self.bearer_format = bearer_format - self.flows = flows - self.open_id_connect_url = open_id_connect_url diff --git a/openapi_core/schema/servers.py b/openapi_core/schema/servers.py new file mode 100644 index 00000000..249c30bc --- /dev/null +++ b/openapi_core/schema/servers.py @@ -0,0 +1,26 @@ +from typing import Any +from typing import Dict + +from jsonschema_path import SchemaPath + + +def is_absolute(url: str) -> bool: + return url.startswith("//") or "://" in url + + +def get_server_default_variables(server: SchemaPath) -> Dict[str, Any]: + if "variables" not in server: + return {} + + defaults = {} + variables = server / "variables" + for name, variable in list(variables.items()): + defaults[name] = variable["default"] + return defaults + + +def get_server_url(server: SchemaPath, **variables: Any) -> str: + if not variables: + variables = get_server_default_variables(server) + assert isinstance(server["url"], str) + return server["url"].format(**variables) diff --git a/openapi_core/schema/servers/exceptions.py b/openapi_core/schema/servers/exceptions.py deleted file mode 100644 index b8076e9d..00000000 --- a/openapi_core/schema/servers/exceptions.py +++ /dev/null @@ -1,16 +0,0 @@ -import attr - -from openapi_core.schema.exceptions import OpenAPIMappingError - - -class OpenAPIServerError(OpenAPIMappingError): - pass - - -@attr.s(hash=True) -class InvalidServer(OpenAPIServerError): - full_url_pattern = attr.ib() - - def __str__(self): - return "Invalid request server {0}".format( - self.full_url_pattern) diff --git a/openapi_core/schema/servers/generators.py b/openapi_core/schema/servers/generators.py deleted file mode 100644 index ca8dbc71..00000000 --- a/openapi_core/schema/servers/generators.py +++ /dev/null @@ -1,67 +0,0 @@ -"""OpenAPI core servers generators module""" -from six import iteritems - -from openapi_core.compat import lru_cache -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.servers.models import Server, ServerVariable - - -class ServersGenerator(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def generate(self, servers_spec): - servers_deref = self.dereferencer.dereference(servers_spec) - for server_spec in servers_deref: - url = server_spec['url'] - variables_spec = server_spec.get('variables', {}) - description = server_spec.get('description') - - extensions = self.extensions_generator.generate(server_spec) - - variables = None - if variables_spec: - variables = self.variables_generator.generate(variables_spec) - - yield Server( - url, - variables=variables, description=description, - extensions=extensions, - ) - - @property - @lru_cache() - def variables_generator(self): - return ServerVariablesGenerator(self.dereferencer) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) - - -class ServerVariablesGenerator(object): - - def __init__(self, dereferencer): - self.dereferencer = dereferencer - - def generate(self, variables_spec): - variables_deref = self.dereferencer.dereference(variables_spec) - - for variable_name, variable_spec in iteritems(variables_deref): - default = variable_spec['default'] - enum = variable_spec.get('enum') - - extensions = self.extensions_generator.generate(variable_spec) - - variable = ServerVariable( - variable_name, default, - enum=enum, extensions=extensions, - ) - yield variable_name, variable - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/servers/models.py b/openapi_core/schema/servers/models.py deleted file mode 100644 index 201db26f..00000000 --- a/openapi_core/schema/servers/models.py +++ /dev/null @@ -1,43 +0,0 @@ -"""OpenAPI core servers models module""" -from six import iteritems - - -class Server(object): - - def __init__(self, url, variables=None, description=None, extensions=None): - self.url = url - self.variables = variables and dict(variables) or {} - self.description = description - - self.extensions = extensions and dict(extensions) or {} - - @property - def default_url(self): - return self.get_url() - - @property - def default_variables(self): - defaults = {} - for name, variable in iteritems(self.variables): - defaults[name] = variable.default - return defaults - - def get_url(self, **variables): - if not variables: - variables = self.default_variables - return self.url.format(**variables) - - def is_absolute(self, url=None): - if url is None: - url = self.url - return url.startswith('//') or '://' in url - - -class ServerVariable(object): - - def __init__(self, name, default, enum=None, extensions=None): - self.name = name - self.default = default - self.enum = enum and list(enum) or [] - - self.extensions = extensions and dict(extensions) or {} diff --git a/openapi_core/schema/shortcuts.py b/openapi_core/schema/shortcuts.py deleted file mode 100644 index 9fb625d3..00000000 --- a/openapi_core/schema/shortcuts.py +++ /dev/null @@ -1,20 +0,0 @@ -"""OpenAPI core schema shortcuts module""" -from jsonschema.validators import RefResolver -from openapi_spec_validator import ( - default_handlers, openapi_v3_spec_validator, -) - -from openapi_core.schema.specs.factories import SpecFactory - - -def create_spec( - spec_dict, spec_url='', handlers=default_handlers, - validate_spec=True, -): - if validate_spec: - openapi_v3_spec_validator.validate(spec_dict, spec_url=spec_url) - - spec_resolver = RefResolver( - spec_url, spec_dict, handlers=handlers) - spec_factory = SpecFactory(spec_resolver) - return spec_factory.create(spec_dict, spec_url=spec_url) diff --git a/openapi_core/schema/specs.py b/openapi_core/schema/specs.py new file mode 100644 index 00000000..0de09b08 --- /dev/null +++ b/openapi_core/schema/specs.py @@ -0,0 +1,8 @@ +from jsonschema_path import SchemaPath + +from openapi_core.schema.servers import get_server_url + + +def get_spec_url(spec: SchemaPath, index: int = 0) -> str: + servers = spec / "servers" + return get_server_url(servers / 0) diff --git a/openapi_core/schema/specs/factories.py b/openapi_core/schema/specs/factories.py deleted file mode 100644 index 1871c84c..00000000 --- a/openapi_core/schema/specs/factories.py +++ /dev/null @@ -1,93 +0,0 @@ -# -*- coding: utf-8 -*- -"""OpenAPI core specs factories module""" - -from openapi_spec_validator.validators import Dereferencer - -from openapi_core.compat import lru_cache -from openapi_core.schema.components.factories import ComponentsFactory -from openapi_core.schema.extensions.generators import ExtensionsGenerator -from openapi_core.schema.infos.factories import InfoFactory -from openapi_core.schema.paths.generators import PathsGenerator -from openapi_core.schema.schemas.registries import SchemaRegistry -from openapi_core.schema.security_requirements.generators import ( - SecurityRequirementsGenerator, -) -from openapi_core.schema.servers.generators import ServersGenerator -from openapi_core.schema.specs.models import Spec - - -class SpecFactory(object): - - def __init__(self, spec_resolver): - self.spec_resolver = spec_resolver - - def create(self, spec_dict, spec_url=''): - spec_dict_deref = self.dereferencer.dereference(spec_dict) - - info_spec = spec_dict_deref.get('info', {}) - servers_spec = spec_dict_deref.get('servers', []) - paths = spec_dict_deref.get('paths', {}) - components_spec = spec_dict_deref.get('components', {}) - security_spec = spec_dict_deref.get('security', []) - - if not servers_spec: - servers_spec = [ - {'url': '/'}, - ] - - extensions = self.extensions_generator.generate(spec_dict_deref) - - info = self.info_factory.create(info_spec) - servers = self.servers_generator.generate(servers_spec) - paths = self.paths_generator.generate(paths) - components = self.components_factory.create(components_spec) - - security = self.security_requirements_generator.generate( - security_spec) - - spec = Spec( - info, list(paths), servers=list(servers), components=components, - security=list(security), extensions=extensions, - _resolver=self.spec_resolver, - ) - return spec - - @property - @lru_cache() - def dereferencer(self): - return Dereferencer(self.spec_resolver) - - @property - @lru_cache() - def schemas_registry(self): - return SchemaRegistry(self.dereferencer) - - @property - @lru_cache() - def info_factory(self): - return InfoFactory(self.dereferencer) - - @property - @lru_cache() - def servers_generator(self): - return ServersGenerator(self.dereferencer) - - @property - @lru_cache() - def paths_generator(self): - return PathsGenerator(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def components_factory(self): - return ComponentsFactory(self.dereferencer, self.schemas_registry) - - @property - @lru_cache() - def security_requirements_generator(self): - return SecurityRequirementsGenerator(self.dereferencer) - - @property - @lru_cache() - def extensions_generator(self): - return ExtensionsGenerator(self.dereferencer) diff --git a/openapi_core/schema/specs/models.py b/openapi_core/schema/specs/models.py deleted file mode 100644 index 6c24ab9e..00000000 --- a/openapi_core/schema/specs/models.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- coding: utf-8 -*- -"""OpenAPI core specs models module""" -import logging - -from openapi_core.compat import partialmethod -from openapi_core.schema.operations.exceptions import InvalidOperation -from openapi_core.schema.paths.exceptions import InvalidPath -from openapi_core.schema.servers.exceptions import InvalidServer - - -log = logging.getLogger(__name__) - - -class Spec(object): - """Represents an OpenAPI Specification for a service.""" - - def __init__( - self, info, paths, servers=None, components=None, - security=None, extensions=None, _resolver=None): - self.info = info - self.paths = paths and dict(paths) - self.servers = servers or [] - self.components = components - self.security = security - - self.extensions = extensions and dict(extensions) or {} - - self._resolver = _resolver - - def __getitem__(self, path_pattern): - return self.get_path(path_pattern) - - @property - def default_url(self): - return self.servers[0].default_url - - def get_server(self, full_url_pattern): - for spec_server in self.servers: - if spec_server.default_url in full_url_pattern: - return spec_server - - raise InvalidServer(full_url_pattern) - - def get_server_url(self, index=0): - return self.servers[index].default_url - - def get_path(self, path_pattern): - try: - return self.paths[path_pattern] - except KeyError: - raise InvalidPath(path_pattern) - - def get_operation(self, path_pattern, http_method): - try: - return self.paths[path_pattern].operations[http_method] - except KeyError: - raise InvalidOperation(path_pattern, http_method) - - def get_schema(self, name): - return self.components.schemas[name] - - # operations shortcuts - - get = partialmethod(get_operation, http_method='get') - put = partialmethod(get_operation, http_method='put') - post = partialmethod(get_operation, http_method='post') - delete = partialmethod(get_operation, http_method='delete') - options = partialmethod(get_operation, http_method='options') - head = partialmethod(get_operation, http_method='head') - patch = partialmethod(get_operation, http_method='patch') diff --git a/openapi_core/security/__init__.py b/openapi_core/security/__init__.py index e69de29b..e2b20490 100644 --- a/openapi_core/security/__init__.py +++ b/openapi_core/security/__init__.py @@ -0,0 +1,5 @@ +from openapi_core.security.factories import SecurityProviderFactory + +__all__ = ["security_provider_factory"] + +security_provider_factory = SecurityProviderFactory() diff --git a/openapi_core/security/exceptions.py b/openapi_core/security/exceptions.py index d6ea4872..c268e6ad 100644 --- a/openapi_core/security/exceptions.py +++ b/openapi_core/security/exceptions.py @@ -1,5 +1,5 @@ from openapi_core.exceptions import OpenAPIError -class SecurityError(OpenAPIError): +class SecurityProviderError(OpenAPIError): pass diff --git a/openapi_core/security/factories.py b/openapi_core/security/factories.py index da34d919..3ab9b79b 100644 --- a/openapi_core/security/factories.py +++ b/openapi_core/security/factories.py @@ -1,19 +1,24 @@ -from openapi_core.schema.security_schemes.enums import SecuritySchemeType -from openapi_core.security.providers import ( - ApiKeyProvider, HttpProvider, UnsupportedProvider, -) +from typing import Any +from typing import Dict +from typing import Type +from jsonschema_path import SchemaPath -class SecurityProviderFactory(object): +from openapi_core.security.providers import ApiKeyProvider +from openapi_core.security.providers import BaseProvider +from openapi_core.security.providers import HttpProvider +from openapi_core.security.providers import UnsupportedProvider - PROVIDERS = { - SecuritySchemeType.API_KEY: ApiKeyProvider, - SecuritySchemeType.HTTP: HttpProvider, + +class SecurityProviderFactory: + PROVIDERS: Dict[str, Type[BaseProvider]] = { + "apiKey": ApiKeyProvider, + "http": HttpProvider, + "oauth2": UnsupportedProvider, + "openIdConnect": UnsupportedProvider, } - def create(self, scheme): - if scheme.type == SecuritySchemeType.API_KEY: - return ApiKeyProvider(scheme) - elif scheme.type == SecuritySchemeType.HTTP: - return HttpProvider(scheme) - return UnsupportedProvider(scheme) + def create(self, scheme: SchemaPath) -> Any: + scheme_type = scheme["type"] + provider_class = self.PROVIDERS[scheme_type] + return provider_class(scheme) diff --git a/openapi_core/security/providers.py b/openapi_core/security/providers.py index 5ee27c54..531feec3 100644 --- a/openapi_core/security/providers.py +++ b/openapi_core/security/providers.py @@ -1,42 +1,51 @@ import warnings +from typing import Any -from openapi_core.security.exceptions import SecurityError +from jsonschema_path import SchemaPath +from openapi_core.datatypes import RequestParameters +from openapi_core.security.exceptions import SecurityProviderError -class BaseProvider(object): - def __init__(self, scheme): +class BaseProvider: + def __init__(self, scheme: SchemaPath): self.scheme = scheme + def __call__(self, parameters: RequestParameters) -> Any: + raise NotImplementedError -class UnsupportedProvider(BaseProvider): - def __call__(self, request): +class UnsupportedProvider(BaseProvider): + def __call__(self, parameters: RequestParameters) -> Any: warnings.warn("Unsupported scheme type") class ApiKeyProvider(BaseProvider): - - def __call__(self, request): - source = getattr(request.parameters, self.scheme.apikey_in.value) - if self.scheme.name not in source: - raise SecurityError("Missing api key parameter.") - return source.get(self.scheme.name) + def __call__(self, parameters: RequestParameters) -> Any: + name = self.scheme["name"] + location = self.scheme["in"] + source = getattr(parameters, location) + if name not in source: + raise SecurityProviderError("Missing api key parameter.") + return source[name] class HttpProvider(BaseProvider): - - def __call__(self, request): - if 'Authorization' not in request.parameters.header: - raise SecurityError('Missing authorization header.') - auth_header = request.parameters.header['Authorization'] + def __call__(self, parameters: RequestParameters) -> Any: + if "Authorization" not in parameters.header: + raise SecurityProviderError("Missing authorization header.") + auth_header = parameters.header["Authorization"] try: - auth_type, encoded_credentials = auth_header.split(' ', 1) + auth_type, encoded_credentials = auth_header.split(" ", 1) except ValueError: - raise SecurityError('Could not parse authorization header.') - - if auth_type.lower() != self.scheme.scheme.value: - raise SecurityError( - 'Unknown authorization method %s' % auth_type) + raise SecurityProviderError( + "Could not parse authorization header." + ) + + scheme = self.scheme["scheme"] + if auth_type.lower() != scheme: + raise SecurityProviderError( + f"Unknown authorization method {auth_type}" + ) return encoded_credentials diff --git a/openapi_core/shortcuts.py b/openapi_core/shortcuts.py index 5d09a0e2..be5c69f9 100644 --- a/openapi_core/shortcuts.py +++ b/openapi_core/shortcuts.py @@ -1,17 +1,247 @@ """OpenAPI core shortcuts module""" -# backward compatibility -from openapi_core.schema.shortcuts import create_spec -from openapi_core.validation.request.shortcuts import ( - spec_validate_body as validate_body, - spec_validate_parameters as validate_parameters, + +from typing import Any +from typing import Optional +from typing import Union + +from jsonschema.validators import _UNSET +from jsonschema_path import SchemaPath + +from openapi_core.app import OpenAPI +from openapi_core.configurations import Config +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.types import AnyRequest +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.request.types import AnyRequestUnmarshallerType +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType +from openapi_core.unmarshalling.request.types import ( + WebhookRequestUnmarshallerType, +) +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, ) -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.shortcuts import ( - spec_validate_data as validate_data +from openapi_core.unmarshalling.response.types import ( + AnyResponseUnmarshallerType, ) -from openapi_core.validation.response.validators import ResponseValidator +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType +from openapi_core.unmarshalling.response.types import ( + WebhookResponseUnmarshallerType, +) +from openapi_core.validation.request.types import AnyRequestValidatorType +from openapi_core.validation.request.types import RequestValidatorType +from openapi_core.validation.request.types import WebhookRequestValidatorType +from openapi_core.validation.response.types import AnyResponseValidatorType +from openapi_core.validation.response.types import ResponseValidatorType +from openapi_core.validation.response.types import WebhookResponseValidatorType + + +def unmarshal_apicall_request( + request: Request, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[RequestUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> RequestUnmarshalResult: + config = Config( + server_base_url=base_url, + request_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_apicall_request(request) + result.raise_for_errors() + return result + + +def unmarshal_webhook_request( + request: WebhookRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookRequestUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> RequestUnmarshalResult: + config = Config( + server_base_url=base_url, + webhook_request_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_webhook_request(request) + result.raise_for_errors() + return result + + +def unmarshal_request( + request: AnyRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyRequestUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> RequestUnmarshalResult: + config = Config( + server_base_url=base_url, + request_unmarshaller_cls=cls or _UNSET, + webhook_request_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_request(request) + result.raise_for_errors() + return result + + +def unmarshal_apicall_response( + request: Request, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[ResponseUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> ResponseUnmarshalResult: + config = Config( + server_base_url=base_url, + response_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_apicall_response( + request, response + ) + result.raise_for_errors() + return result + + +def unmarshal_webhook_response( + request: WebhookRequest, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookResponseUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> ResponseUnmarshalResult: + config = Config( + server_base_url=base_url, + webhook_response_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_webhook_response( + request, response + ) + result.raise_for_errors() + return result + + +def unmarshal_response( + request: AnyRequest, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyResponseUnmarshallerType] = None, + **unmarshaller_kwargs: Any, +) -> ResponseUnmarshalResult: + config = Config( + server_base_url=base_url, + response_unmarshaller_cls=cls or _UNSET, + webhook_response_unmarshaller_cls=cls or _UNSET, + **unmarshaller_kwargs, + ) + result = OpenAPI(spec, config=config).unmarshal_response(request, response) + result.raise_for_errors() + return result + + +def validate_request( + request: AnyRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyRequestValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + request_validator_cls=cls or _UNSET, + webhook_request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_request(request) + + +def validate_response( + request: Union[Request, WebhookRequest], + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[AnyResponseValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + response_validator_cls=cls or _UNSET, + webhook_response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_response(request, response) + + +def validate_apicall_request( + request: Request, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[RequestValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_apicall_request(request) + + +def validate_webhook_request( + request: WebhookRequest, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookRequestValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + webhook_request_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_webhook_request(request) + + +def validate_apicall_response( + request: Request, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[ResponseValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_apicall_response( + request, response + ) + -__all__ = [ - 'create_spec', 'validate_body', 'validate_parameters', 'validate_data', - 'RequestValidator', 'ResponseValidator', -] +def validate_webhook_response( + request: WebhookRequest, + response: Response, + spec: SchemaPath, + base_url: Optional[str] = None, + cls: Optional[WebhookResponseValidatorType] = None, + **validator_kwargs: Any, +) -> None: + config = Config( + server_base_url=base_url, + webhook_response_validator_cls=cls or _UNSET, + **validator_kwargs, + ) + return OpenAPI(spec, config=config).validate_webhook_response( + request, response + ) diff --git a/openapi_core/schema/components/__init__.py b/openapi_core/spec/__init__.py similarity index 100% rename from openapi_core/schema/components/__init__.py rename to openapi_core/spec/__init__.py diff --git a/openapi_core/spec/paths.py b/openapi_core/spec/paths.py new file mode 100644 index 00000000..a1846ee0 --- /dev/null +++ b/openapi_core/spec/paths.py @@ -0,0 +1,13 @@ +import warnings +from typing import Any + +from jsonschema_path import SchemaPath + + +class Spec(SchemaPath): + def __init__(self, *args: Any, **kwargs: Any): + warnings.warn( + "Spec is deprecated. Use SchemaPath from jsonschema-path package.", + DeprecationWarning, + ) + super().__init__(*args, **kwargs) diff --git a/openapi_core/templating/datatypes.py b/openapi_core/templating/datatypes.py index 7087d9e3..68aa8a58 100644 --- a/openapi_core/templating/datatypes.py +++ b/openapi_core/templating/datatypes.py @@ -1,13 +1,15 @@ -import attr +from dataclasses import dataclass +from typing import Dict +from typing import Optional -@attr.s -class TemplateResult(object): - pattern = attr.ib(default=None) - variables = attr.ib(default=None) +@dataclass +class TemplateResult: + pattern: str + variables: Optional[Dict[str, str]] = None @property - def resolved(self): + def resolved(self) -> str: if not self.variables: return self.pattern return self.pattern.format(**self.variables) diff --git a/openapi_core/schema/contacts/__init__.py b/openapi_core/templating/media_types/__init__.py similarity index 100% rename from openapi_core/schema/contacts/__init__.py rename to openapi_core/templating/media_types/__init__.py diff --git a/openapi_core/templating/media_types/datatypes.py b/openapi_core/templating/media_types/datatypes.py new file mode 100644 index 00000000..77e01f66 --- /dev/null +++ b/openapi_core/templating/media_types/datatypes.py @@ -0,0 +1,3 @@ +from collections import namedtuple + +MediaType = namedtuple("MediaType", ["mime_type", "parameters", "media_type"]) diff --git a/openapi_core/templating/media_types/exceptions.py b/openapi_core/templating/media_types/exceptions.py new file mode 100644 index 00000000..190d349e --- /dev/null +++ b/openapi_core/templating/media_types/exceptions.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import List + +from openapi_core.exceptions import OpenAPIError + + +class MediaTypeFinderError(OpenAPIError): + """Media type finder error""" + + +@dataclass +class MediaTypeNotFound(MediaTypeFinderError): + mimetype: str + availableMimetypes: List[str] + + def __str__(self) -> str: + return ( + f"Content for the following mimetype not found: {self.mimetype}. " + f"Valid mimetypes: {self.availableMimetypes}" + ) diff --git a/openapi_core/templating/media_types/finders.py b/openapi_core/templating/media_types/finders.py new file mode 100644 index 00000000..1be2a022 --- /dev/null +++ b/openapi_core/templating/media_types/finders.py @@ -0,0 +1,70 @@ +"""OpenAPI core templating media types finders module""" + +import fnmatch +import re +from typing import Mapping +from typing import Tuple + +from jsonschema_path import SchemaPath + +from openapi_core.templating.media_types.datatypes import MediaType +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound + + +class MediaTypeFinder: + def __init__(self, content: SchemaPath): + self.content = content + + def get_first(self) -> MediaType: + mimetype, media_type = next(self.content.items()) + return MediaType(mimetype, {}, media_type) + + def find(self, mimetype: str) -> MediaType: + if mimetype is None: + raise MediaTypeNotFound(mimetype, list(self.content.keys())) + + mime_type, parameters = self._parse_mimetype(mimetype) + + # simple mime type + for m in [mimetype, mime_type]: + if m in self.content: + return MediaType(mime_type, parameters, self.content / m) + + # range mime type + if mime_type: + for key, value in self.content.items(): + if fnmatch.fnmatch(mime_type, key): + return MediaType(key, parameters, value) + + raise MediaTypeNotFound(mimetype, list(self.content.keys())) + + def _parse_mimetype(self, mimetype: str) -> Tuple[str, Mapping[str, str]]: + mimetype_parts = mimetype.split(";") + mime_type = mimetype_parts[0].lower().rstrip() + parameters = {} + if len(mimetype_parts) > 1: + parameters_list = ( + self._parse_parameter(param_str) + for param_str in mimetype_parts[1:] + ) + parameters = dict(parameters_list) + return mime_type, parameters + + def _parse_parameter(self, parameter: str) -> Tuple[str, str]: + """Parse a parameter according to RFC 9110. + + See https://www.rfc-editor.org/rfc/rfc9110.html#name-parameters + + Important points: + * parameter names are case-insensitive + * parameter values are case-sensitive + except "charset" which is case-insensitive + https://www.rfc-editor.org/rfc/rfc2046#section-4.1.2 + """ + name, value = parameter.split("=") + name = name.lower().lstrip() + # remove surrounding quotes from value + value = re.sub('^"(.*)"$', r"\1", value, count=1) + if name == "charset": + value = value.lower() + return name, value.rstrip() diff --git a/openapi_core/templating/paths/__init__.py b/openapi_core/templating/paths/__init__.py index e69de29b..93e94f74 100644 --- a/openapi_core/templating/paths/__init__.py +++ b/openapi_core/templating/paths/__init__.py @@ -0,0 +1,7 @@ +from openapi_core.templating.paths.finders import APICallPathFinder +from openapi_core.templating.paths.finders import WebhookPathFinder + +__all__ = [ + "APICallPathFinder", + "WebhookPathFinder", +] diff --git a/openapi_core/templating/paths/datatypes.py b/openapi_core/templating/paths/datatypes.py new file mode 100644 index 00000000..fd32702d --- /dev/null +++ b/openapi_core/templating/paths/datatypes.py @@ -0,0 +1,12 @@ +"""OpenAPI core templating paths datatypes module""" + +from collections import namedtuple + +Path = namedtuple("Path", ["path", "path_result"]) +PathOperation = namedtuple( + "PathOperation", ["path", "operation", "path_result"] +) +PathOperationServer = namedtuple( + "PathOperationServer", + ["path", "operation", "server", "path_result", "server_result"], +) diff --git a/openapi_core/templating/paths/exceptions.py b/openapi_core/templating/paths/exceptions.py index 0ed2e7e4..8eccde4a 100644 --- a/openapi_core/templating/paths/exceptions.py +++ b/openapi_core/templating/paths/exceptions.py @@ -1,4 +1,4 @@ -import attr +from dataclasses import dataclass from openapi_core.exceptions import OpenAPIError @@ -7,30 +7,40 @@ class PathError(OpenAPIError): """Path error""" -@attr.s(hash=True) +@dataclass class PathNotFound(PathError): - """Find path error""" - url = attr.ib() + """Path not found""" - def __str__(self): - return "Path not found for {0}".format(self.url) + url: str + def __str__(self) -> str: + return f"Path not found for {self.url}" -@attr.s(hash=True) + +@dataclass +class PathsNotFound(PathNotFound): + """Paths not found""" + + def __str__(self) -> str: + return f"Paths not found in spec: {self.url}" + + +@dataclass class OperationNotFound(PathError): """Find path operation error""" - url = attr.ib() - method = attr.ib() - def __str__(self): - return "Operation {0} not found for {1}".format( - self.method, self.url) + url: str + method: str + + def __str__(self) -> str: + return f"Operation {self.method} not found for {self.url}" -@attr.s(hash=True) +@dataclass class ServerNotFound(PathError): """Find server error""" - url = attr.ib() - def __str__(self): - return "Server not found for {0}".format(self.url) + url: str + + def __str__(self) -> str: + return f"Server not found for {self.url}" diff --git a/openapi_core/templating/paths/finders.py b/openapi_core/templating/paths/finders.py index 1ec284e2..bd4dc033 100644 --- a/openapi_core/templating/paths/finders.py +++ b/openapi_core/templating/paths/finders.py @@ -1,99 +1,71 @@ """OpenAPI core templating paths finders module""" + +from typing import Optional + +from jsonschema_path import SchemaPath from more_itertools import peekable -from six import iteritems -from six.moves.urllib.parse import urljoin, urlparse -from openapi_core.templating.datatypes import TemplateResult -from openapi_core.templating.util import parse, search -from openapi_core.templating.paths.exceptions import ( - PathNotFound, OperationNotFound, ServerNotFound, -) +from openapi_core.templating.paths.datatypes import PathOperationServer +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.paths.iterators import SimpleOperationsIterator +from openapi_core.templating.paths.iterators import SimplePathsIterator +from openapi_core.templating.paths.iterators import SimpleServersIterator +from openapi_core.templating.paths.iterators import TemplatePathsIterator +from openapi_core.templating.paths.iterators import TemplateServersIterator +from openapi_core.templating.paths.protocols import OperationsIterator +from openapi_core.templating.paths.protocols import PathsIterator +from openapi_core.templating.paths.protocols import ServersIterator -class PathFinder(object): +class BasePathFinder: + paths_iterator: PathsIterator = NotImplemented + operations_iterator: OperationsIterator = NotImplemented + servers_iterator: ServersIterator = NotImplemented - def __init__(self, spec, base_url=None): + def __init__(self, spec: SchemaPath, base_url: Optional[str] = None): self.spec = spec self.base_url = base_url - def find(self, request): - paths_iter = self._get_paths_iter(request.full_url_pattern) + def find(self, method: str, name: str) -> PathOperationServer: + paths_iter = self.paths_iterator( + name, + self.spec, + base_url=self.base_url, + ) paths_iter_peek = peekable(paths_iter) if not paths_iter_peek: - raise PathNotFound(request.full_url_pattern) + raise PathNotFound(name) - operations_iter = self._get_operations_iter( - request.method, paths_iter_peek) + operations_iter = self.operations_iterator( + method, + paths_iter_peek, + self.spec, + base_url=self.base_url, + ) operations_iter_peek = peekable(operations_iter) if not operations_iter_peek: - raise OperationNotFound(request.full_url_pattern, request.method) + raise OperationNotFound(name, method) - servers_iter = self._get_servers_iter( - request.full_url_pattern, operations_iter_peek) + servers_iter = self.servers_iterator( + name, operations_iter_peek, self.spec, base_url=self.base_url + ) try: return next(servers_iter) except StopIteration: - raise ServerNotFound(request.full_url_pattern) - - def _get_paths_iter(self, full_url_pattern): - template_paths = [] - for path_pattern, path in iteritems(self.spec.paths): - # simple path. - # Return right away since it is always the most concrete - if full_url_pattern.endswith(path_pattern): - path_result = TemplateResult(path_pattern, {}) - yield (path, path_result) - # template path - else: - result = search(path_pattern, full_url_pattern) - if result: - path_result = TemplateResult(path_pattern, result.named) - template_paths.append((path, path_result)) - - # Fewer variables -> more concrete path - for path in sorted(template_paths, key=lambda p: len(p[1].variables)): - yield path - - def _get_operations_iter(self, request_method, paths_iter): - for path, path_result in paths_iter: - if request_method not in path.operations: - continue - operation = path.operations[request_method] - yield (path, operation, path_result) - - def _get_servers_iter(self, full_url_pattern, ooperations_iter): - for path, operation, path_result in ooperations_iter: - servers = path.servers or operation.servers or self.spec.servers - for server in servers: - server_url_pattern = full_url_pattern.rsplit( - path_result.resolved, 1)[0] - server_url = server.url - if not server.is_absolute(): - # relative to absolute url - if self.base_url is not None: - server_url = urljoin(self.base_url, server.url) - # if no base url check only path part - else: - server_url_pattern = urlparse(server_url_pattern).path - if server_url.endswith('/'): - server_url = server_url[:-1] - # simple path - if server_url_pattern == server_url: - server_result = TemplateResult(server.url, {}) - yield ( - path, operation, server, - path_result, server_result, - ) - # template path - else: - result = parse(server.url, server_url_pattern) - if result: - server_result = TemplateResult( - server.url, result.named) - yield ( - path, operation, server, - path_result, server_result, - ) + raise ServerNotFound(name) + + +class APICallPathFinder(BasePathFinder): + paths_iterator: PathsIterator = TemplatePathsIterator("paths") + operations_iterator: OperationsIterator = SimpleOperationsIterator() + servers_iterator: ServersIterator = TemplateServersIterator() + + +class WebhookPathFinder(APICallPathFinder): + paths_iterator = SimplePathsIterator("webhooks") + servers_iterator = SimpleServersIterator() diff --git a/openapi_core/templating/paths/iterators.py b/openapi_core/templating/paths/iterators.py new file mode 100644 index 00000000..f78d3342 --- /dev/null +++ b/openapi_core/templating/paths/iterators.py @@ -0,0 +1,185 @@ +from typing import Iterator +from typing import List +from typing import Optional +from urllib.parse import urljoin +from urllib.parse import urlparse + +from jsonschema_path import SchemaPath + +from openapi_core.schema.servers import is_absolute +from openapi_core.templating.datatypes import TemplateResult +from openapi_core.templating.paths.datatypes import Path +from openapi_core.templating.paths.datatypes import PathOperation +from openapi_core.templating.paths.datatypes import PathOperationServer +from openapi_core.templating.paths.exceptions import PathsNotFound +from openapi_core.templating.paths.util import template_path_len +from openapi_core.templating.util import parse +from openapi_core.templating.util import search + + +class SimplePathsIterator: + def __init__(self, paths_part: str): + self.paths_part = paths_part + + def __call__( + self, name: str, spec: SchemaPath, base_url: Optional[str] = None + ) -> Iterator[Path]: + paths = spec / self.paths_part + if not paths.exists(): + raise PathsNotFound(paths.as_uri()) + for path_name, path in list(paths.items()): + if name == path_name: + path_result = TemplateResult(path_name, {}) + yield Path(path, path_result) + + +class TemplatePathsIterator: + def __init__(self, paths_part: str): + self.paths_part = paths_part + + def __call__( + self, name: str, spec: SchemaPath, base_url: Optional[str] = None + ) -> Iterator[Path]: + paths = spec / self.paths_part + if not paths.exists(): + raise PathsNotFound(paths.as_uri()) + template_paths: List[Path] = [] + for path_pattern, path in list(paths.items()): + # simple path. + # Return right away since it is always the most concrete + if name.endswith(path_pattern): + path_result = TemplateResult(path_pattern, {}) + yield Path(path, path_result) + # template path + else: + result = search(path_pattern, name) + if result: + path_result = TemplateResult(path_pattern, result.named) + template_paths.append(Path(path, path_result)) + + # Fewer variables -> more concrete path + yield from sorted(template_paths, key=template_path_len) + + +class SimpleOperationsIterator: + def __call__( + self, + method: str, + paths_iter: Iterator[Path], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperation]: + for path, path_result in paths_iter: + if method not in path: + continue + operation = path / method + yield PathOperation(path, operation, path_result) + + +class CatchAllMethodOperationsIterator(SimpleOperationsIterator): + def __init__(self, ca_method_name: str, ca_operation_name: str): + self.ca_method_name = ca_method_name + self.ca_operation_name = ca_operation_name + + def __call__( + self, + method: str, + paths_iter: Iterator[Path], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperation]: + if method == self.ca_method_name: + yield from super().__call__( + self.ca_operation_name, paths_iter, spec, base_url=base_url + ) + else: + yield from super().__call__( + method, paths_iter, spec, base_url=base_url + ) + + +class SimpleServersIterator: + def __call__( + self, + name: str, + operations_iter: Iterator[PathOperation], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperationServer]: + for path, operation, path_result in operations_iter: + yield PathOperationServer( + path, + operation, + None, + path_result, + {}, + ) + + +class TemplateServersIterator: + def __call__( + self, + name: str, + operations_iter: Iterator[PathOperation], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperationServer]: + for path, operation, path_result in operations_iter: + servers = ( + path.get("servers", None) + or operation.get("servers", None) + or spec.get("servers", None) + ) + if not servers: + servers = [SchemaPath.from_dict({"url": "/"})] + for server in servers: + server_url_pattern = name.rsplit(path_result.resolved, 1)[0] + server_url = server["url"] + if not is_absolute(server_url): + # relative to absolute url + if base_url is not None: + server_url = urljoin(base_url, server["url"]) + # if no base url check only path part + else: + server_url_pattern = urlparse(server_url_pattern).path + if server_url.endswith("/"): + server_url = server_url[:-1] + # simple path + if server_url_pattern == server_url: + server_result = TemplateResult(server["url"], {}) + yield PathOperationServer( + path, + operation, + server, + path_result, + server_result, + ) + # template path + else: + result = parse(server["url"], server_url_pattern) + if result: + server_result = TemplateResult( + server["url"], result.named + ) + yield PathOperationServer( + path, + operation, + server, + path_result, + server_result, + ) + # servers should'n end with tailing slash + # but let's search for this too + server_url_pattern += "/" + result = parse(server["url"], server_url_pattern) + if result: + server_result = TemplateResult( + server["url"], result.named + ) + yield PathOperationServer( + path, + operation, + server, + path_result, + server_result, + ) diff --git a/openapi_core/templating/paths/protocols.py b/openapi_core/templating/paths/protocols.py new file mode 100644 index 00000000..e73c690c --- /dev/null +++ b/openapi_core/templating/paths/protocols.py @@ -0,0 +1,39 @@ +from typing import Iterator +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from jsonschema_path import SchemaPath + +from openapi_core.templating.paths.datatypes import Path +from openapi_core.templating.paths.datatypes import PathOperation +from openapi_core.templating.paths.datatypes import PathOperationServer + + +@runtime_checkable +class PathsIterator(Protocol): + def __call__( + self, name: str, spec: SchemaPath, base_url: Optional[str] = None + ) -> Iterator[Path]: ... + + +@runtime_checkable +class OperationsIterator(Protocol): + def __call__( + self, + method: str, + paths_iter: Iterator[Path], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperation]: ... + + +@runtime_checkable +class ServersIterator(Protocol): + def __call__( + self, + name: str, + operations_iter: Iterator[PathOperation], + spec: SchemaPath, + base_url: Optional[str] = None, + ) -> Iterator[PathOperationServer]: ... diff --git a/openapi_core/templating/paths/types.py b/openapi_core/templating/paths/types.py new file mode 100644 index 00000000..6067a18a --- /dev/null +++ b/openapi_core/templating/paths/types.py @@ -0,0 +1,5 @@ +from typing import Type + +from openapi_core.templating.paths.finders import BasePathFinder + +PathFinderType = Type[BasePathFinder] diff --git a/openapi_core/templating/paths/util.py b/openapi_core/templating/paths/util.py new file mode 100644 index 00000000..a8b6440a --- /dev/null +++ b/openapi_core/templating/paths/util.py @@ -0,0 +1,5 @@ +from openapi_core.templating.paths.datatypes import Path + + +def template_path_len(template_path: Path) -> int: + return len(template_path[1].variables) diff --git a/openapi_core/schema/content/__init__.py b/openapi_core/templating/responses/__init__.py similarity index 100% rename from openapi_core/schema/content/__init__.py rename to openapi_core/templating/responses/__init__.py diff --git a/openapi_core/templating/responses/exceptions.py b/openapi_core/templating/responses/exceptions.py new file mode 100644 index 00000000..39e1a012 --- /dev/null +++ b/openapi_core/templating/responses/exceptions.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import List + +from openapi_core.exceptions import OpenAPIError + + +class ResponseFinderError(OpenAPIError): + """Response finder error""" + + +@dataclass +class ResponseNotFound(ResponseFinderError): + """Find response error""" + + http_status: str + availableresponses: List[str] + + def __str__(self) -> str: + return f"Unknown response http status: {str(self.http_status)}" diff --git a/openapi_core/templating/responses/finders.py b/openapi_core/templating/responses/finders.py new file mode 100644 index 00000000..b05cda91 --- /dev/null +++ b/openapi_core/templating/responses/finders.py @@ -0,0 +1,22 @@ +from jsonschema_path import SchemaPath + +from openapi_core.templating.responses.exceptions import ResponseNotFound + + +class ResponseFinder: + def __init__(self, responses: SchemaPath): + self.responses = responses + + def find(self, http_status: str = "default") -> SchemaPath: + if http_status in self.responses: + return self.responses / http_status + + # try range + http_status_range = f"{http_status[0]}XX" + if http_status_range in self.responses: + return self.responses / http_status_range + + if "default" not in self.responses: + raise ResponseNotFound(http_status, list(self.responses.keys())) + + return self.responses / "default" diff --git a/openapi_core/schema/extensions/__init__.py b/openapi_core/templating/security/__init__.py similarity index 100% rename from openapi_core/schema/extensions/__init__.py rename to openapi_core/templating/security/__init__.py diff --git a/openapi_core/templating/security/exceptions.py b/openapi_core/templating/security/exceptions.py new file mode 100644 index 00000000..7f426a53 --- /dev/null +++ b/openapi_core/templating/security/exceptions.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import List + +from openapi_core.exceptions import OpenAPIError + + +class SecurityFinderError(OpenAPIError): + """Security finder error""" + + +@dataclass +class SecurityNotFound(SecurityFinderError): + """Find security error""" + + schemes: List[List[str]] + + def __str__(self) -> str: + return f"Security not found. Schemes not valid for any requirement: {str(self.schemes)}" diff --git a/openapi_core/templating/util.py b/openapi_core/templating/util.py index 8923fd6b..ef5dfa71 100644 --- a/openapi_core/templating/util.py +++ b/openapi_core/templating/util.py @@ -1,32 +1,38 @@ +from typing import Any +from typing import Optional + +from parse import Match from parse import Parser -class ExtendedParser(Parser): - def _handle_field(self, field): +class ExtendedParser(Parser): # type: ignore + def _handle_field(self, field: str) -> Any: # handle as path parameter field field = field[1:-1] path_parameter_field = "{%s:PathParameter}" % field - return super(ExtendedParser, self)._handle_field( - path_parameter_field) + return super()._handle_field(path_parameter_field) + +class PathParameter: + name = "PathParameter" + pattern = r"[^\/]*" -def parse_path_parameter(text): - return text + def __call__(self, text: str) -> str: + return text -parse_path_parameter.pattern = r"[^\/]+" -parse_path_parameter.name = "PathParameter" +parse_path_parameter = PathParameter() -def search(path_pattern, full_url_pattern): +def search(path_pattern: str, full_url_pattern: str) -> Optional[Match]: extra_types = {parse_path_parameter.name: parse_path_parameter} p = ExtendedParser(path_pattern, extra_types) - p._expression = p._expression + '$' + p._expression = p._expression + "$" return p.search(full_url_pattern) -def parse(server_url, server_url_pattern): +def parse(server_url: str, server_url_pattern: str) -> Match: extra_types = {parse_path_parameter.name: parse_path_parameter} p = ExtendedParser(server_url, extra_types) - p._expression = '^' + p._expression + p._expression = "^" + p._expression return p.parse(server_url_pattern) diff --git a/openapi_core/testing/__init__.py b/openapi_core/testing/__init__.py index 28b50ca0..32a89814 100644 --- a/openapi_core/testing/__init__.py +++ b/openapi_core/testing/__init__.py @@ -1,10 +1,9 @@ """OpenAPI core testing module""" -from openapi_core.testing.mock import MockRequestFactory, MockResponseFactory -# backward compatibility -MockRequest = MockRequestFactory.create -MockResponse = MockResponseFactory.create +from openapi_core.testing.requests import MockRequest +from openapi_core.testing.responses import MockResponse __all__ = [ - 'MockRequestFactory', 'MockResponseFactory', 'MockRequest', 'MockResponse', + "MockRequest", + "MockResponse", ] diff --git a/openapi_core/testing/datatypes.py b/openapi_core/testing/datatypes.py index 963ea118..8f4ee138 100644 --- a/openapi_core/testing/datatypes.py +++ b/openapi_core/testing/datatypes.py @@ -1,18 +1,21 @@ -class ResultMock(object): +from typing import Optional +from openapi_core.datatypes import Parameters + + +class ResultMock: def __init__( - self, body=None, parameters=None, data=None, error_to_raise=None): + self, + body: Optional[str] = None, + parameters: Optional[Parameters] = None, + data: Optional[str] = None, + error_to_raise: Optional[Exception] = None, + ): self.body = body self.parameters = parameters self.data = data self.error_to_raise = error_to_raise - def raise_for_errors(self): + def raise_for_errors(self) -> None: if self.error_to_raise is not None: raise self.error_to_raise - - if self.parameters is not None: - return self.parameters - - if self.data is not None: - return self.data diff --git a/openapi_core/testing/factories.py b/openapi_core/testing/factories.py deleted file mode 100644 index 7ac561e8..00000000 --- a/openapi_core/testing/factories.py +++ /dev/null @@ -1,11 +0,0 @@ -class FactoryClassMock(object): - - _instances = {} - - def __new__(cls, obj): - if obj not in cls._instances: - cls._instances[obj] = object.__new__(cls) - return cls._instances[obj] - - def __init__(self, obj): - self.obj = obj diff --git a/openapi_core/testing/mock.py b/openapi_core/testing/mock.py deleted file mode 100644 index d305f444..00000000 --- a/openapi_core/testing/mock.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OpenAPI core testing mock module""" -# backward compatibility -from openapi_core.testing.requests import MockRequestFactory -from openapi_core.testing.responses import MockResponseFactory - -__all__ = ['MockRequestFactory', 'MockResponseFactory'] diff --git a/openapi_core/testing/requests.py b/openapi_core/testing/requests.py index 7d3d0ed9..942e7ba0 100644 --- a/openapi_core/testing/requests.py +++ b/openapi_core/testing/requests.py @@ -1,33 +1,43 @@ """OpenAPI core testing requests module""" -from six.moves.urllib.parse import urljoin + +from typing import Any +from typing import Dict +from typing import Optional + +from werkzeug.datastructures import Headers from werkzeug.datastructures import ImmutableMultiDict -from openapi_core.validation.request.datatypes import ( - RequestParameters, OpenAPIRequest, -) +from openapi_core.datatypes import RequestParameters -class MockRequestFactory(object): +class MockRequest: + def __init__( + self, + host_url: str, + method: str, + path: str, + path_pattern: Optional[str] = None, + args: Optional[Dict[str, Any]] = None, + view_args: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, Any]] = None, + cookies: Optional[Dict[str, Any]] = None, + data: Optional[bytes] = None, + content_type: str = "application/json", + ): + self.host_url = host_url + self.method = method.lower() + self.path = path + self.path_pattern = path_pattern + self.args = args + self.view_args = view_args + self.headers = headers + self.cookies = cookies + self.body = data or b"" + self.content_type = content_type - @classmethod - def create( - cls, host_url, method, path, path_pattern=None, args=None, - view_args=None, headers=None, cookies=None, data=None, - mimetype='application/json'): - parameters = RequestParameters( - path=view_args or {}, - query=ImmutableMultiDict(args or []), - header=headers or {}, - cookie=cookies or {}, - ) - path_pattern = path_pattern or path - method = method.lower() - body = data or '' - full_url_pattern = urljoin(host_url, path_pattern) - return OpenAPIRequest( - full_url_pattern=full_url_pattern, - method=method, - parameters=parameters, - body=body, - mimetype=mimetype, + self.parameters = RequestParameters( + path=self.view_args or {}, + query=ImmutableMultiDict(self.args or {}), + header=Headers(self.headers or {}), + cookie=ImmutableMultiDict(self.cookies or {}), ) diff --git a/openapi_core/testing/responses.py b/openapi_core/testing/responses.py index af96d0b0..f7cf41da 100644 --- a/openapi_core/testing/responses.py +++ b/openapi_core/testing/responses.py @@ -1,13 +1,21 @@ """OpenAPI core testing responses module""" -from openapi_core.validation.response.datatypes import OpenAPIResponse +from typing import Any +from typing import Dict +from typing import Optional -class MockResponseFactory(object): +from werkzeug.datastructures import Headers - @classmethod - def create(cls, data, status_code=200, mimetype='application/json'): - return OpenAPIResponse( - data=data, - status_code=status_code, - mimetype=mimetype, - ) + +class MockResponse: + def __init__( + self, + data: bytes, + status_code: int = 200, + headers: Optional[Dict[str, Any]] = None, + content_type: str = "application/json", + ): + self.data = data + self.status_code = status_code + self.headers = Headers(headers or {}) + self.content_type = content_type diff --git a/openapi_core/types.py b/openapi_core/types.py new file mode 100644 index 00000000..ab47f7a5 --- /dev/null +++ b/openapi_core/types.py @@ -0,0 +1,8 @@ +"""OpenAPI core types""" + +from typing import Union + +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest + +AnyRequest = Union[Request, WebhookRequest] diff --git a/openapi_core/typing.py b/openapi_core/typing.py new file mode 100644 index 00000000..7cb12f9d --- /dev/null +++ b/openapi_core/typing.py @@ -0,0 +1,6 @@ +from typing import TypeVar + +#: The type of request within an integration. +RequestType = TypeVar("RequestType") +#: The type of response within an integration. +ResponseType = TypeVar("ResponseType") diff --git a/openapi_core/unmarshalling/configurations.py b/openapi_core/unmarshalling/configurations.py new file mode 100644 index 00000000..27cdccd7 --- /dev/null +++ b/openapi_core/unmarshalling/configurations.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Optional + +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.validation.configurations import ValidatorConfig + + +@dataclass +class UnmarshallerConfig(ValidatorConfig): + """Unmarshaller configuration dataclass. + + Attributes: + schema_unmarshallers_factory + Schema unmarshallers factory. + extra_format_unmarshallers + Extra format unmarshallers. + """ + + schema_unmarshallers_factory: Optional[SchemaUnmarshallersFactory] = None + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None diff --git a/openapi_core/unmarshalling/datatypes.py b/openapi_core/unmarshalling/datatypes.py new file mode 100644 index 00000000..8c009c5f --- /dev/null +++ b/openapi_core/unmarshalling/datatypes.py @@ -0,0 +1,15 @@ +"""OpenAPI core validation datatypes module""" + +from dataclasses import dataclass +from typing import Iterable + +from openapi_core.exceptions import OpenAPIError + + +@dataclass +class BaseUnmarshalResult: + errors: Iterable[OpenAPIError] + + def raise_for_errors(self) -> None: + for error in self.errors: + raise error diff --git a/openapi_core/unmarshalling/integrations.py b/openapi_core/unmarshalling/integrations.py new file mode 100644 index 00000000..293ce7dd --- /dev/null +++ b/openapi_core/unmarshalling/integrations.py @@ -0,0 +1,69 @@ +"""OpenAPI core unmarshalling processors module""" + +from typing import Generic + +from openapi_core.app import OpenAPI +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.typing import RequestType +from openapi_core.typing import ResponseType +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.validation.integrations import ValidationIntegration + + +class UnmarshallingIntegration( + ValidationIntegration[RequestType, ResponseType] +): + def unmarshal_request( + self, request: RequestType + ) -> RequestUnmarshalResult: + openapi_request = self.get_openapi_request(request) + return self.openapi.unmarshal_request( + openapi_request, + ) + + def unmarshal_response( + self, + request: RequestType, + response: ResponseType, + ) -> ResponseUnmarshalResult: + openapi_request = self.get_openapi_request(request) + openapi_response = self.get_openapi_response(response) + return self.openapi.unmarshal_response( + openapi_request, openapi_response + ) + + +class AsyncUnmarshallingIntegration(Generic[RequestType, ResponseType]): + def __init__( + self, + openapi: OpenAPI, + ): + self.openapi = openapi + + async def get_openapi_request(self, request: RequestType) -> Request: + raise NotImplementedError + + async def get_openapi_response(self, response: ResponseType) -> Response: + raise NotImplementedError + + async def unmarshal_request( + self, + request: RequestType, + ) -> RequestUnmarshalResult: + openapi_request = await self.get_openapi_request(request) + return self.openapi.unmarshal_request(openapi_request) + + async def unmarshal_response( + self, + request: RequestType, + response: ResponseType, + ) -> ResponseUnmarshalResult: + openapi_request = await self.get_openapi_request(request) + openapi_response = await self.get_openapi_response(response) + return self.openapi.unmarshal_response( + openapi_request, openapi_response + ) diff --git a/openapi_core/unmarshalling/processors.py b/openapi_core/unmarshalling/processors.py new file mode 100644 index 00000000..12374089 --- /dev/null +++ b/openapi_core/unmarshalling/processors.py @@ -0,0 +1,68 @@ +"""OpenAPI core unmarshalling processors module""" + +from openapi_core.typing import RequestType +from openapi_core.typing import ResponseType +from openapi_core.unmarshalling.integrations import ( + AsyncUnmarshallingIntegration, +) +from openapi_core.unmarshalling.integrations import UnmarshallingIntegration +from openapi_core.unmarshalling.typing import AsyncValidRequestHandlerCallable +from openapi_core.unmarshalling.typing import ErrorsHandlerCallable +from openapi_core.unmarshalling.typing import ValidRequestHandlerCallable + + +class UnmarshallingProcessor( + UnmarshallingIntegration[RequestType, ResponseType] +): + def handle_request( + self, + request: RequestType, + valid_handler: ValidRequestHandlerCallable[ResponseType], + errors_handler: ErrorsHandlerCallable[ResponseType], + ) -> ResponseType: + request_unmarshal_result = self.unmarshal_request( + request, + ) + if request_unmarshal_result.errors: + return errors_handler(request_unmarshal_result.errors) + return valid_handler(request_unmarshal_result) + + def handle_response( + self, + request: RequestType, + response: ResponseType, + errors_handler: ErrorsHandlerCallable[ResponseType], + ) -> ResponseType: + response_unmarshal_result = self.unmarshal_response(request, response) + if response_unmarshal_result.errors: + return errors_handler(response_unmarshal_result.errors) + return response + + +class AsyncUnmarshallingProcessor( + AsyncUnmarshallingIntegration[RequestType, ResponseType] +): + async def handle_request( + self, + request: RequestType, + valid_handler: AsyncValidRequestHandlerCallable[ResponseType], + errors_handler: ErrorsHandlerCallable[ResponseType], + ) -> ResponseType: + request_unmarshal_result = await self.unmarshal_request(request) + if request_unmarshal_result.errors: + return errors_handler(request_unmarshal_result.errors) + result = await valid_handler(request_unmarshal_result) + return result + + async def handle_response( + self, + request: RequestType, + response: ResponseType, + errors_handler: ErrorsHandlerCallable[ResponseType], + ) -> ResponseType: + response_unmarshal_result = await self.unmarshal_response( + request, response + ) + if response_unmarshal_result.errors: + return errors_handler(response_unmarshal_result.errors) + return response diff --git a/openapi_core/unmarshalling/request/__init__.py b/openapi_core/unmarshalling/request/__init__.py new file mode 100644 index 00000000..1b41432e --- /dev/null +++ b/openapi_core/unmarshalling/request/__init__.py @@ -0,0 +1,43 @@ +"""OpenAPI core unmarshalling request module""" + +from typing import Mapping + +from openapi_spec_validator.versions import consts as versions +from openapi_spec_validator.versions.datatypes import SpecVersion + +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType +from openapi_core.unmarshalling.request.types import ( + WebhookRequestUnmarshallerType, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestUnmarshaller, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + V31RequestUnmarshaller, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + V31WebhookRequestUnmarshaller, +) + +__all__ = [ + "UNMARSHALLERS", + "WEBHOOK_UNMARSHALLERS", + "V3RequestUnmarshaller", + "V3WebhookRequestUnmarshaller", + "V30RequestUnmarshaller", + "V31RequestUnmarshaller", + "V31WebhookRequestUnmarshaller", +] + +# versions mapping +UNMARSHALLERS: Mapping[SpecVersion, RequestUnmarshallerType] = { + versions.OPENAPIV30: V30RequestUnmarshaller, + versions.OPENAPIV31: V31RequestUnmarshaller, +} +WEBHOOK_UNMARSHALLERS: Mapping[SpecVersion, WebhookRequestUnmarshallerType] = { + versions.OPENAPIV31: V31WebhookRequestUnmarshaller, +} + +# alias to the latest v3 version +V3RequestUnmarshaller = V31RequestUnmarshaller +V3WebhookRequestUnmarshaller = V31WebhookRequestUnmarshaller diff --git a/openapi_core/unmarshalling/request/datatypes.py b/openapi_core/unmarshalling/request/datatypes.py new file mode 100644 index 00000000..47d520c3 --- /dev/null +++ b/openapi_core/unmarshalling/request/datatypes.py @@ -0,0 +1,17 @@ +"""OpenAPI core unmarshalling request datatypes module""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import Any + +from openapi_core.datatypes import Parameters +from openapi_core.unmarshalling.datatypes import BaseUnmarshalResult + + +@dataclass +class RequestUnmarshalResult(BaseUnmarshalResult): + body: Any | None = None + parameters: Parameters = field(default_factory=Parameters) + security: dict[str, str] | None = None diff --git a/openapi_core/unmarshalling/request/processors.py b/openapi_core/unmarshalling/request/processors.py new file mode 100644 index 00000000..a2e04e13 --- /dev/null +++ b/openapi_core/unmarshalling/request/processors.py @@ -0,0 +1,34 @@ +from typing import Any +from typing import Optional + +from jsonschema_path import SchemaPath + +from openapi_core.protocols import Request +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.request.protocols import RequestUnmarshaller +from openapi_core.unmarshalling.request.types import RequestUnmarshallerType + + +class RequestUnmarshallingProcessor: + def __init__( + self, + spec: SchemaPath, + request_unmarshaller_cls: RequestUnmarshallerType, + **unmarshaller_kwargs: Any + ) -> None: + self.spec = spec + self.request_unmarshaller_cls = request_unmarshaller_cls + self.unmarshaller_kwargs = unmarshaller_kwargs + + self._request_unmarshaller_cached: Optional[RequestUnmarshaller] = None + + @property + def request_unmarshaller(self) -> RequestUnmarshaller: + if self._request_unmarshaller_cached is None: + self._request_unmarshaller_cached = self.request_unmarshaller_cls( + self.spec, **self.unmarshaller_kwargs + ) + return self._request_unmarshaller_cached + + def process(self, request: Request) -> RequestUnmarshalResult: + return self.request_unmarshaller.unmarshal(request) diff --git a/openapi_core/unmarshalling/request/protocols.py b/openapi_core/unmarshalling/request/protocols.py new file mode 100644 index 00000000..43a18cbe --- /dev/null +++ b/openapi_core/unmarshalling/request/protocols.py @@ -0,0 +1,99 @@ +"""OpenAPI core validation request protocols module""" + +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest +from openapi_core.security import security_provider_factory +from openapi_core.security.factories import SecurityProviderFactory +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@runtime_checkable +class RequestUnmarshaller(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): ... + + def unmarshal( + self, + request: Request, + ) -> RequestUnmarshalResult: ... + + +@runtime_checkable +class WebhookRequestUnmarshaller(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): ... + + def unmarshal( + self, + request: WebhookRequest, + ) -> RequestUnmarshalResult: ... diff --git a/openapi_core/unmarshalling/request/types.py b/openapi_core/unmarshalling/request/types.py new file mode 100644 index 00000000..e889bfec --- /dev/null +++ b/openapi_core/unmarshalling/request/types.py @@ -0,0 +1,13 @@ +from typing import Type +from typing import Union + +from openapi_core.unmarshalling.request.protocols import RequestUnmarshaller +from openapi_core.unmarshalling.request.protocols import ( + WebhookRequestUnmarshaller, +) + +RequestUnmarshallerType = Type[RequestUnmarshaller] +WebhookRequestUnmarshallerType = Type[WebhookRequestUnmarshaller] +AnyRequestUnmarshallerType = Union[ + RequestUnmarshallerType, WebhookRequestUnmarshallerType +] diff --git a/openapi_core/unmarshalling/request/unmarshallers.py b/openapi_core/unmarshalling/request/unmarshallers.py new file mode 100644 index 00000000..efd45930 --- /dev/null +++ b/openapi_core/unmarshalling/request/unmarshallers.py @@ -0,0 +1,436 @@ +from typing import Optional + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import BaseRequest +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest +from openapi_core.security import security_provider_factory +from openapi_core.security.factories import SecurityProviderFactory +from openapi_core.templating.paths.exceptions import PathError +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.schemas import ( + oas30_write_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas import ( + oas31_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.unmarshalling.unmarshallers import BaseUnmarshaller +from openapi_core.util import chainiters +from openapi_core.validation.request.exceptions import MissingRequestBody +from openapi_core.validation.request.exceptions import ParametersError +from openapi_core.validation.request.exceptions import ( + RequestBodyValidationError, +) +from openapi_core.validation.request.exceptions import SecurityValidationError +from openapi_core.validation.request.validators import APICallRequestValidator +from openapi_core.validation.request.validators import BaseRequestValidator +from openapi_core.validation.request.validators import V30RequestBodyValidator +from openapi_core.validation.request.validators import ( + V30RequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V30RequestSecurityValidator, +) +from openapi_core.validation.request.validators import V30RequestValidator +from openapi_core.validation.request.validators import V31RequestBodyValidator +from openapi_core.validation.request.validators import ( + V31RequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V31RequestSecurityValidator, +) +from openapi_core.validation.request.validators import V31RequestValidator +from openapi_core.validation.request.validators import ( + V31WebhookRequestBodyValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestSecurityValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestValidator, +) +from openapi_core.validation.request.validators import WebhookRequestValidator +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +class BaseRequestUnmarshaller(BaseRequestValidator, BaseUnmarshaller): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): + BaseUnmarshaller.__init__( + self, + spec, + base_url=base_url, + style_deserializers_factory=style_deserializers_factory, + media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, + schema_validators_factory=schema_validators_factory, + path_finder_cls=path_finder_cls, + spec_validator_cls=spec_validator_cls, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + extra_media_type_deserializers=extra_media_type_deserializers, + schema_unmarshallers_factory=schema_unmarshallers_factory, + format_unmarshallers=format_unmarshallers, + extra_format_unmarshallers=extra_format_unmarshallers, + ) + BaseRequestValidator.__init__( + self, + spec, + base_url=base_url, + style_deserializers_factory=style_deserializers_factory, + media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, + schema_validators_factory=schema_validators_factory, + path_finder_cls=path_finder_cls, + spec_validator_cls=spec_validator_cls, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + extra_media_type_deserializers=extra_media_type_deserializers, + security_provider_factory=security_provider_factory, + ) + + def _unmarshal( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> RequestUnmarshalResult: + try: + security = self._get_security(request.parameters, operation) + except SecurityValidationError as exc: + return RequestUnmarshalResult(errors=[exc]) + + try: + params = self._get_parameters(request.parameters, operation, path) + except ParametersError as exc: + params = exc.parameters + params_errors = exc.errors + else: + params_errors = [] + + try: + body = self._get_body( + request.body, request.content_type, operation + ) + except MissingRequestBody: + body = None + body_errors = [] + except RequestBodyValidationError as exc: + body = None + body_errors = [exc] + else: + body_errors = [] + + errors = list(chainiters(params_errors, body_errors)) + return RequestUnmarshalResult( + errors=errors, + body=body, + parameters=params, + security=security, + ) + + def _unmarshal_body( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> RequestUnmarshalResult: + try: + body = self._get_body( + request.body, request.content_type, operation + ) + except MissingRequestBody: + body = None + errors = [] + except RequestBodyValidationError as exc: + body = None + errors = [exc] + else: + errors = [] + + return RequestUnmarshalResult( + errors=errors, + body=body, + ) + + def _unmarshal_parameters( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> RequestUnmarshalResult: + try: + params = self._get_parameters(request.parameters, path, operation) + except ParametersError as exc: + params = exc.parameters + params_errors = exc.errors + else: + params_errors = [] + + return RequestUnmarshalResult( + errors=params_errors, + parameters=params, + ) + + def _unmarshal_security( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> RequestUnmarshalResult: + try: + security = self._get_security(request.parameters, operation) + except SecurityValidationError as exc: + return RequestUnmarshalResult(errors=[exc]) + + return RequestUnmarshalResult( + errors=[], + security=security, + ) + + +class BaseAPICallRequestUnmarshaller(BaseRequestUnmarshaller): + pass + + +class BaseWebhookRequestUnmarshaller(BaseRequestUnmarshaller): + pass + + +class APICallRequestUnmarshaller( + APICallRequestValidator, BaseAPICallRequestUnmarshaller +): + def unmarshal(self, request: Request) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal(request, operation, path) + + +class APICallRequestBodyUnmarshaller( + APICallRequestValidator, BaseAPICallRequestUnmarshaller +): + def unmarshal(self, request: Request) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_body(request, operation, path) + + +class APICallRequestParametersUnmarshaller( + APICallRequestValidator, BaseAPICallRequestUnmarshaller +): + def unmarshal(self, request: Request) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_parameters(request, operation, path) + + +class APICallRequestSecurityUnmarshaller( + APICallRequestValidator, BaseAPICallRequestUnmarshaller +): + def unmarshal(self, request: Request) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_security(request, operation, path) + + +class WebhookRequestUnmarshaller( + WebhookRequestValidator, BaseWebhookRequestUnmarshaller +): + def unmarshal(self, request: WebhookRequest) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal(request, operation, path) + + +class WebhookRequestBodyUnmarshaller( + WebhookRequestValidator, BaseWebhookRequestUnmarshaller +): + def unmarshal(self, request: WebhookRequest) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_body(request, operation, path) + + +class WebhookRequestParametersUnmarshaller( + WebhookRequestValidator, BaseWebhookRequestUnmarshaller +): + def unmarshal(self, request: WebhookRequest) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_parameters(request, operation, path) + + +class WebhookRequestSecuritysUnmarshaller( + WebhookRequestValidator, BaseWebhookRequestUnmarshaller +): + def unmarshal(self, request: WebhookRequest) -> RequestUnmarshalResult: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return RequestUnmarshalResult(errors=[exc]) + + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + return self._unmarshal_security(request, operation, path) + + +class V30RequestBodyUnmarshaller( + V30RequestBodyValidator, APICallRequestBodyUnmarshaller +): + schema_unmarshallers_factory = oas30_write_schema_unmarshallers_factory + + +class V30RequestParametersUnmarshaller( + V30RequestParametersValidator, APICallRequestParametersUnmarshaller +): + schema_unmarshallers_factory = oas30_write_schema_unmarshallers_factory + + +class V30RequestSecurityUnmarshaller( + V30RequestSecurityValidator, APICallRequestSecurityUnmarshaller +): + schema_unmarshallers_factory = oas30_write_schema_unmarshallers_factory + + +class V30RequestUnmarshaller(V30RequestValidator, APICallRequestUnmarshaller): + schema_unmarshallers_factory = oas30_write_schema_unmarshallers_factory + + +class V31RequestBodyUnmarshaller( + V31RequestBodyValidator, APICallRequestBodyUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31RequestParametersUnmarshaller( + V31RequestParametersValidator, APICallRequestParametersUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31RequestSecurityUnmarshaller( + V31RequestSecurityValidator, APICallRequestSecurityUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31RequestUnmarshaller(V31RequestValidator, APICallRequestUnmarshaller): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookRequestBodyUnmarshaller( + V31WebhookRequestBodyValidator, WebhookRequestBodyUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookRequestParametersUnmarshaller( + V31WebhookRequestParametersValidator, WebhookRequestParametersUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookRequestSecurityUnmarshaller( + V31WebhookRequestSecurityValidator, WebhookRequestSecuritysUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookRequestUnmarshaller( + V31WebhookRequestValidator, WebhookRequestUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory diff --git a/openapi_core/unmarshalling/response/__init__.py b/openapi_core/unmarshalling/response/__init__.py new file mode 100644 index 00000000..e1ebf5d5 --- /dev/null +++ b/openapi_core/unmarshalling/response/__init__.py @@ -0,0 +1,45 @@ +"""OpenAPI core unmarshalling response module""" + +from typing import Mapping + +from openapi_spec_validator.versions import consts as versions +from openapi_spec_validator.versions.datatypes import SpecVersion + +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType +from openapi_core.unmarshalling.response.types import ( + WebhookResponseUnmarshallerType, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V31ResponseUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V31WebhookResponseUnmarshaller, +) + +__all__ = [ + "UNMARSHALLERS", + "WEBHOOK_UNMARSHALLERS", + "V3ResponseUnmarshaller", + "V3WebhookResponseUnmarshaller", + "V30ResponseUnmarshaller", + "V31ResponseUnmarshaller", + "V31WebhookResponseUnmarshaller", +] + +# versions mapping +UNMARSHALLERS: Mapping[SpecVersion, ResponseUnmarshallerType] = { + versions.OPENAPIV30: V30ResponseUnmarshaller, + versions.OPENAPIV31: V31ResponseUnmarshaller, +} +WEBHOOK_UNMARSHALLERS: Mapping[ + SpecVersion, WebhookResponseUnmarshallerType +] = { + versions.OPENAPIV31: V31WebhookResponseUnmarshaller, +} + +# alias to the latest v3 version +V3ResponseUnmarshaller = V31ResponseUnmarshaller +V3WebhookResponseUnmarshaller = V31WebhookResponseUnmarshaller diff --git a/openapi_core/unmarshalling/response/datatypes.py b/openapi_core/unmarshalling/response/datatypes.py new file mode 100644 index 00000000..bb92d3db --- /dev/null +++ b/openapi_core/unmarshalling/response/datatypes.py @@ -0,0 +1,15 @@ +"""OpenAPI core unmarshalling response datatypes module""" + +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Dict +from typing import Optional + +from openapi_core.unmarshalling.datatypes import BaseUnmarshalResult + + +@dataclass +class ResponseUnmarshalResult(BaseUnmarshalResult): + data: Optional[str] = None + headers: Dict[str, Any] = field(default_factory=dict) diff --git a/openapi_core/unmarshalling/response/processors.py b/openapi_core/unmarshalling/response/processors.py new file mode 100644 index 00000000..9218a054 --- /dev/null +++ b/openapi_core/unmarshalling/response/processors.py @@ -0,0 +1,43 @@ +from typing import Any +from typing import Optional + +from jsonschema_path import SchemaPath + +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.unmarshalling.response.protocols import ResponseUnmarshaller +from openapi_core.unmarshalling.response.types import ResponseUnmarshallerType + + +class ResponseUnmarshallingProcessor: + def __init__( + self, + spec: SchemaPath, + response_unmarshaller_cls: ResponseUnmarshallerType, + **unmarshaller_kwargs: Any + ) -> None: + self.spec = spec + self.response_unmarshaller_cls = response_unmarshaller_cls + self.unmarshaller_kwargs = unmarshaller_kwargs + + self._response_unmarshaller_cached: Optional[ResponseUnmarshaller] = ( + None + ) + + @property + def response_unmarshaller(self) -> ResponseUnmarshaller: + if self._response_unmarshaller_cached is None: + self._response_unmarshaller_cached = ( + self.response_unmarshaller_cls( + self.spec, **self.unmarshaller_kwargs + ) + ) + return self._response_unmarshaller_cached + + def process( + self, request: Request, response: Response + ) -> ResponseUnmarshalResult: + return self.response_unmarshaller.unmarshal(request, response) diff --git a/openapi_core/unmarshalling/response/protocols.py b/openapi_core/unmarshalling/response/protocols.py new file mode 100644 index 00000000..de90c58d --- /dev/null +++ b/openapi_core/unmarshalling/response/protocols.py @@ -0,0 +1,100 @@ +"""OpenAPI core validation response protocols module""" + +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@runtime_checkable +class ResponseUnmarshaller(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): ... + + def unmarshal( + self, + request: Request, + response: Response, + ) -> ResponseUnmarshalResult: ... + + +@runtime_checkable +class WebhookResponseUnmarshaller(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): ... + + def unmarshal( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseUnmarshalResult: ... diff --git a/openapi_core/unmarshalling/response/types.py b/openapi_core/unmarshalling/response/types.py new file mode 100644 index 00000000..bc3e004e --- /dev/null +++ b/openapi_core/unmarshalling/response/types.py @@ -0,0 +1,13 @@ +from typing import Type +from typing import Union + +from openapi_core.unmarshalling.response.protocols import ResponseUnmarshaller +from openapi_core.unmarshalling.response.protocols import ( + WebhookResponseUnmarshaller, +) + +ResponseUnmarshallerType = Type[ResponseUnmarshaller] +WebhookResponseUnmarshallerType = Type[WebhookResponseUnmarshaller] +AnyResponseUnmarshallerType = Union[ + ResponseUnmarshallerType, WebhookResponseUnmarshallerType +] diff --git a/openapi_core/unmarshalling/response/unmarshallers.py b/openapi_core/unmarshalling/response/unmarshallers.py new file mode 100644 index 00000000..4f02f5c7 --- /dev/null +++ b/openapi_core/unmarshalling/response/unmarshallers.py @@ -0,0 +1,302 @@ +from jsonschema_path import SchemaPath + +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.templating.paths.exceptions import PathError +from openapi_core.templating.responses.exceptions import ResponseFinderError +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.unmarshalling.schemas import ( + oas30_read_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas import ( + oas31_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.unmarshallers import BaseUnmarshaller +from openapi_core.util import chainiters +from openapi_core.validation.response.exceptions import DataValidationError +from openapi_core.validation.response.exceptions import HeadersError +from openapi_core.validation.response.validators import ( + APICallResponseValidator, +) +from openapi_core.validation.response.validators import BaseResponseValidator +from openapi_core.validation.response.validators import ( + V30ResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V30ResponseHeadersValidator, +) +from openapi_core.validation.response.validators import V30ResponseValidator +from openapi_core.validation.response.validators import ( + V31ResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V31ResponseHeadersValidator, +) +from openapi_core.validation.response.validators import V31ResponseValidator +from openapi_core.validation.response.validators import ( + V31WebhookResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseHeadersValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseValidator, +) +from openapi_core.validation.response.validators import ( + WebhookResponseValidator, +) + + +class BaseResponseUnmarshaller(BaseResponseValidator, BaseUnmarshaller): + def _unmarshal( + self, + response: Response, + operation: SchemaPath, + ) -> ResponseUnmarshalResult: + try: + operation_response = self._find_operation_response( + response.status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + try: + validated_data = self._get_data( + response.data, response.content_type, operation_response + ) + except DataValidationError as exc: + validated_data = None + data_errors = [exc] + else: + data_errors = [] + + try: + validated_headers = self._get_headers( + response.headers, operation_response + ) + except HeadersError as exc: + validated_headers = exc.headers + headers_errors = exc.context + else: + headers_errors = [] + + errors = list(chainiters(data_errors, headers_errors)) + return ResponseUnmarshalResult( + errors=errors, + data=validated_data, + headers=validated_headers, + ) + + def _unmarshal_data( + self, + response: Response, + operation: SchemaPath, + ) -> ResponseUnmarshalResult: + try: + operation_response = self._find_operation_response( + response.status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + try: + validated = self._get_data( + response.data, response.content_type, operation_response + ) + except DataValidationError as exc: + validated = None + data_errors = [exc] + else: + data_errors = [] + + return ResponseUnmarshalResult( + errors=data_errors, + data=validated, + ) + + def _unmarshal_headers( + self, + response: Response, + operation: SchemaPath, + ) -> ResponseUnmarshalResult: + try: + operation_response = self._find_operation_response( + response.status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + try: + validated = self._get_headers(response.headers, operation_response) + except HeadersError as exc: + validated = exc.headers + headers_errors = exc.context + else: + headers_errors = [] + + return ResponseUnmarshalResult( + errors=headers_errors, + headers=validated, + ) + + +class APICallResponseUnmarshaller( + APICallResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: Request, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal(response, operation) + + +class APICallResponseDataUnmarshaller( + APICallResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: Request, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal_data(response, operation) + + +class APICallResponseHeadersUnmarshaller( + APICallResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: Request, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal_headers(response, operation) + + +class WebhookResponseUnmarshaller( + WebhookResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal(response, operation) + + +class WebhookResponseDataUnmarshaller( + WebhookResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal_data(response, operation) + + +class WebhookResponseHeadersUnmarshaller( + WebhookResponseValidator, BaseResponseUnmarshaller +): + def unmarshal( + self, + request: WebhookRequest, + response: Response, + ) -> ResponseUnmarshalResult: + try: + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + return ResponseUnmarshalResult(errors=[exc]) + + return self._unmarshal_headers(response, operation) + + +class V30ResponseDataUnmarshaller( + V30ResponseDataValidator, APICallResponseDataUnmarshaller +): + schema_unmarshallers_factory = oas30_read_schema_unmarshallers_factory + + +class V30ResponseHeadersUnmarshaller( + V30ResponseHeadersValidator, APICallResponseHeadersUnmarshaller +): + schema_unmarshallers_factory = oas30_read_schema_unmarshallers_factory + + +class V30ResponseUnmarshaller( + V30ResponseValidator, APICallResponseUnmarshaller +): + schema_unmarshallers_factory = oas30_read_schema_unmarshallers_factory + + +class V31ResponseDataUnmarshaller( + V31ResponseDataValidator, APICallResponseDataUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31ResponseHeadersUnmarshaller( + V31ResponseHeadersValidator, APICallResponseHeadersUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31ResponseUnmarshaller( + V31ResponseValidator, APICallResponseUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseDataUnmarshaller( + V31WebhookResponseDataValidator, WebhookResponseDataUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseHeadersUnmarshaller( + V31WebhookResponseHeadersValidator, WebhookResponseHeadersUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory + + +class V31WebhookResponseUnmarshaller( + V31WebhookResponseValidator, WebhookResponseUnmarshaller +): + schema_unmarshallers_factory = oas31_schema_unmarshallers_factory diff --git a/openapi_core/unmarshalling/schemas/__init__.py b/openapi_core/unmarshalling/schemas/__init__.py index e69de29b..bb0aa65f 100644 --- a/openapi_core/unmarshalling/schemas/__init__.py +++ b/openapi_core/unmarshalling/schemas/__init__.py @@ -0,0 +1,96 @@ +from collections import OrderedDict + +from isodate.isodatetime import parse_datetime + +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.unmarshalling.schemas.unmarshallers import AnyUnmarshaller +from openapi_core.unmarshalling.schemas.unmarshallers import ArrayUnmarshaller +from openapi_core.unmarshalling.schemas.unmarshallers import ( + MultiTypeUnmarshaller, +) +from openapi_core.unmarshalling.schemas.unmarshallers import ObjectUnmarshaller +from openapi_core.unmarshalling.schemas.unmarshallers import ( + PrimitiveUnmarshaller, +) +from openapi_core.unmarshalling.schemas.unmarshallers import TypesUnmarshaller +from openapi_core.unmarshalling.schemas.util import format_byte +from openapi_core.unmarshalling.schemas.util import format_date +from openapi_core.unmarshalling.schemas.util import format_uuid +from openapi_core.validation.schemas import ( + oas30_read_schema_validators_factory, +) +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, +) +from openapi_core.validation.schemas import oas31_schema_validators_factory + +__all__ = [ + "oas30_format_unmarshallers", + "oas31_format_unmarshallers", + "oas30_write_schema_unmarshallers_factory", + "oas30_read_schema_unmarshallers_factory", + "oas31_schema_unmarshallers_factory", +] + +oas30_unmarshallers_dict = OrderedDict( + [ + ("object", ObjectUnmarshaller), + ("array", ArrayUnmarshaller), + ("boolean", PrimitiveUnmarshaller), + ("integer", PrimitiveUnmarshaller), + ("number", PrimitiveUnmarshaller), + ("string", PrimitiveUnmarshaller), + ] +) +oas31_unmarshallers_dict = oas30_unmarshallers_dict.copy() +oas31_unmarshallers_dict.update( + { + "null": PrimitiveUnmarshaller, + } +) + +oas30_types_unmarshaller = TypesUnmarshaller( + oas30_unmarshallers_dict, + AnyUnmarshaller, +) +oas31_types_unmarshaller = TypesUnmarshaller( + oas31_unmarshallers_dict, + AnyUnmarshaller, + multi=MultiTypeUnmarshaller, +) + +oas30_format_unmarshallers = { + # string compatible + "date": format_date, + "date-time": parse_datetime, + "binary": bytes, + "uuid": format_uuid, + "byte": format_byte, +} +oas31_format_unmarshallers = oas30_format_unmarshallers + +oas30_write_schema_unmarshallers_factory = SchemaUnmarshallersFactory( + oas30_write_schema_validators_factory, + oas30_types_unmarshaller, + format_unmarshallers=oas30_format_unmarshallers, +) + +oas30_read_schema_unmarshallers_factory = SchemaUnmarshallersFactory( + oas30_read_schema_validators_factory, + oas30_types_unmarshaller, + format_unmarshallers=oas30_format_unmarshallers, +) + +oas31_schema_unmarshallers_factory = SchemaUnmarshallersFactory( + oas31_schema_validators_factory, + oas31_types_unmarshaller, + format_unmarshallers=oas31_format_unmarshallers, +) + +# alias to v31 version (request/response are the same bcs no context needed) +oas31_request_schema_unmarshallers_factory = oas31_schema_unmarshallers_factory +oas31_response_schema_unmarshallers_factory = ( + oas31_schema_unmarshallers_factory +) diff --git a/openapi_core/unmarshalling/schemas/datatypes.py b/openapi_core/unmarshalling/schemas/datatypes.py new file mode 100644 index 00000000..2e1892a1 --- /dev/null +++ b/openapi_core/unmarshalling/schemas/datatypes.py @@ -0,0 +1,6 @@ +from typing import Any +from typing import Callable +from typing import Dict + +FormatUnmarshaller = Callable[[Any], Any] +FormatUnmarshallersDict = Dict[str, FormatUnmarshaller] diff --git a/openapi_core/unmarshalling/schemas/enums.py b/openapi_core/unmarshalling/schemas/enums.py deleted file mode 100644 index ffe4ed55..00000000 --- a/openapi_core/unmarshalling/schemas/enums.py +++ /dev/null @@ -1,7 +0,0 @@ -"""OpenAPI core unmarshalling schemas enums module""" -from enum import Enum - - -class UnmarshalContext(Enum): - REQUEST = 'request' - RESPONSE = 'response' diff --git a/openapi_core/unmarshalling/schemas/exceptions.py b/openapi_core/unmarshalling/schemas/exceptions.py index 67eede67..433de337 100644 --- a/openapi_core/unmarshalling/schemas/exceptions.py +++ b/openapi_core/unmarshalling/schemas/exceptions.py @@ -1,56 +1,21 @@ -import attr +from dataclasses import dataclass from openapi_core.exceptions import OpenAPIError class UnmarshalError(OpenAPIError): """Schema unmarshal operation error""" - pass - - -class ValidateError(UnmarshalError): - """Schema validate operation error""" - pass class UnmarshallerError(UnmarshalError): """Unmarshaller error""" - pass - - -@attr.s(hash=True) -class InvalidSchemaValue(ValidateError): - value = attr.ib() - type = attr.ib() - schema_errors = attr.ib(factory=tuple) - - def __str__(self): - return ( - "Value {value} not valid for schema of type {type}: {errors}" - ).format(value=self.value, type=self.type, errors=self.schema_errors) -@attr.s(hash=True) -class InvalidSchemaFormatValue(UnmarshallerError): - """Value failed to format with formatter""" - value = attr.ib() - type = attr.ib() - original_exception = attr.ib() - - def __str__(self): - return ( - "Failed to format value {value} to format {type}: {exception}" - ).format( - value=self.value, type=self.type, - exception=self.original_exception, - ) - - -@attr.s(hash=True) +@dataclass class FormatterNotFoundError(UnmarshallerError): """Formatter not found to unmarshal""" - type_format = attr.ib() - def __str__(self): - return "Formatter not found for {format} format".format( - format=self.type_format) + type_format: str + + def __str__(self) -> str: + return f"Formatter not found for {self.type_format} format" diff --git a/openapi_core/unmarshalling/schemas/factories.py b/openapi_core/unmarshalling/schemas/factories.py index 60f90dc4..6472cab5 100644 --- a/openapi_core/unmarshalling/schemas/factories.py +++ b/openapi_core/unmarshalling/schemas/factories.py @@ -1,90 +1,78 @@ import warnings +from typing import Optional -from openapi_schema_validator import OAS30Validator +from jsonschema_path import SchemaPath -from openapi_core.schema.schemas.enums import SchemaType, SchemaFormat -from openapi_core.schema.schemas.models import Schema -from openapi_core.unmarshalling.schemas.enums import UnmarshalContext +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) from openapi_core.unmarshalling.schemas.exceptions import ( FormatterNotFoundError, ) from openapi_core.unmarshalling.schemas.unmarshallers import ( - StringUnmarshaller, IntegerUnmarshaller, NumberUnmarshaller, - BooleanUnmarshaller, ArrayUnmarshaller, ObjectUnmarshaller, - AnyUnmarshaller, + FormatsUnmarshaller, ) +from openapi_core.unmarshalling.schemas.unmarshallers import SchemaUnmarshaller +from openapi_core.unmarshalling.schemas.unmarshallers import TypesUnmarshaller +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory -class SchemaUnmarshallersFactory(object): - - PRIMITIVE_UNMARSHALLERS = { - SchemaType.STRING: StringUnmarshaller, - SchemaType.INTEGER: IntegerUnmarshaller, - SchemaType.NUMBER: NumberUnmarshaller, - SchemaType.BOOLEAN: BooleanUnmarshaller, - } - COMPLEX_UNMARSHALLERS = { - SchemaType.ARRAY: ArrayUnmarshaller, - SchemaType.OBJECT: ObjectUnmarshaller, - SchemaType.ANY: AnyUnmarshaller, - } - - CONTEXT_VALIDATION = { - UnmarshalContext.REQUEST: 'write', - UnmarshalContext.RESPONSE: 'read', - } - +class SchemaUnmarshallersFactory: def __init__( - self, resolver=None, format_checker=None, - custom_formatters=None, context=None): - self.resolver = resolver - self.format_checker = format_checker - if custom_formatters is None: - custom_formatters = {} - self.custom_formatters = custom_formatters - self.context = context - - def create(self, schema, type_override=None): + self, + schema_validators_factory: SchemaValidatorsFactory, + types_unmarshaller: TypesUnmarshaller, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): + self.schema_validators_factory = schema_validators_factory + self.types_unmarshaller = types_unmarshaller + if format_unmarshallers is None: + format_unmarshallers = {} + self.format_unmarshallers = format_unmarshallers + + def create( + self, + schema: SchemaPath, + format_validators: Optional[FormatValidatorsDict] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ) -> SchemaUnmarshaller: """Create unmarshaller from the schema.""" - if not isinstance(schema, Schema): - raise TypeError("schema not type of Schema") - if schema.deprecated: - warnings.warn("The schema is deprecated", DeprecationWarning) - - schema_type = type_override or schema.type - if schema_type in self.PRIMITIVE_UNMARSHALLERS: - klass = self.PRIMITIVE_UNMARSHALLERS[schema_type] - kwargs = dict(schema=schema) + if schema is None: + raise TypeError("Invalid schema") - elif schema_type in self.COMPLEX_UNMARSHALLERS: - klass = self.COMPLEX_UNMARSHALLERS[schema_type] - kwargs = dict( - schema=schema, unmarshallers_factory=self, - context=self.context, - ) - - formatter = self.get_formatter(klass.FORMATTERS, schema.format) - - if formatter is None: - raise FormatterNotFoundError(schema.format) - - validator = self.get_validator(schema) - - return klass(formatter, validator, **kwargs) - - def get_formatter(self, default_formatters, type_format=SchemaFormat.NONE): - try: - schema_format = SchemaFormat(type_format) - except ValueError: - return self.custom_formatters.get(type_format) - else: - return default_formatters.get(schema_format) + if schema.getkey("deprecated", False): + warnings.warn("The schema is deprecated", DeprecationWarning) - def get_validator(self, schema): - kwargs = { - 'resolver': self.resolver, - 'format_checker': self.format_checker, - } - if self.context is not None: - kwargs[self.CONTEXT_VALIDATION[self.context]] = True - return OAS30Validator(schema.__dict__, **kwargs) + if extra_format_validators is None: + extra_format_validators = {} + schema_validator = self.schema_validators_factory.create( + schema, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + ) + + schema_format = schema.getkey("format") + + formats_unmarshaller = FormatsUnmarshaller( + format_unmarshallers or self.format_unmarshallers, + extra_format_unmarshallers, + ) + + # FIXME: don;t raise exception on unknown format + # See https://github.com/python-openapi/openapi-core/issues/515 + if ( + schema_format + and schema_format not in schema_validator + and schema_format not in formats_unmarshaller + ): + raise FormatterNotFoundError(schema_format) + + return SchemaUnmarshaller( + schema, + schema_validator, + self.types_unmarshaller, + formats_unmarshaller, + ) diff --git a/openapi_core/unmarshalling/schemas/formatters.py b/openapi_core/unmarshalling/schemas/formatters.py deleted file mode 100644 index b0fed280..00000000 --- a/openapi_core/unmarshalling/schemas/formatters.py +++ /dev/null @@ -1,18 +0,0 @@ -class Formatter(object): - - def validate(self, value): - return True - - def unmarshal(self, value): - return value - - @classmethod - def from_callables(cls, validate=None, unmarshal=None): - attrs = {} - if validate is not None: - attrs['validate'] = staticmethod(validate) - if unmarshal is not None: - attrs['unmarshal'] = staticmethod(unmarshal) - - klass = type('Formatter', (cls, ), attrs) - return klass() diff --git a/openapi_core/unmarshalling/schemas/unmarshallers.py b/openapi_core/unmarshalling/schemas/unmarshallers.py index 83a47dc9..1df9ed09 100644 --- a/openapi_core/unmarshalling/schemas/unmarshallers.py +++ b/openapi_core/unmarshalling/schemas/unmarshallers.py @@ -1,298 +1,311 @@ -from functools import partial import logging - -from isodate.isodatetime import parse_datetime - -from openapi_schema_validator._types import ( - is_array, is_bool, is_integer, - is_object, is_number, is_string, -) -from openapi_schema_validator._format import oas30_format_checker -from six import text_type, binary_type -from six import iteritems - -from openapi_core.extensions.models.factories import ModelFactory -from openapi_core.schema.schemas.enums import SchemaFormat, SchemaType -from openapi_core.schema.schemas.models import Schema -from openapi_core.schema.schemas.types import NoValue -from openapi_core.unmarshalling.schemas.enums import UnmarshalContext -from openapi_core.unmarshalling.schemas.exceptions import ( - UnmarshalError, ValidateError, InvalidSchemaValue, - InvalidSchemaFormatValue, -) -from openapi_core.unmarshalling.schemas.formatters import Formatter -from openapi_core.unmarshalling.schemas.util import ( - forcebool, format_date, format_byte, format_uuid, - format_number, +from typing import Any +from typing import Iterable +from typing import List +from typing import Mapping +from typing import Optional +from typing import Type +from typing import Union + +from jsonschema_path import SchemaPath + +from openapi_core.extensions.models.factories import ModelPathFactory +from openapi_core.schema.schemas import get_properties +from openapi_core.unmarshalling.schemas.datatypes import FormatUnmarshaller +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, ) +from openapi_core.validation.schemas.validators import SchemaValidator log = logging.getLogger(__name__) -class PrimitiveTypeUnmarshaller(object): - - FORMATTERS = {} - - def __init__(self, formatter, validator, schema): - self.formatter = formatter - self.validator = validator - self.schema = schema - - def __call__(self, value=NoValue): - if value is NoValue: - value = self.schema.default - if value is None: - return - - self.validate(value) - - return self.unmarshal(value) - - def _formatter_validate(self, value): - result = self.formatter.validate(value) - if not result: - raise InvalidSchemaValue(value, self.schema.type) - - def validate(self, value): - errors_iter = self.validator.iter_errors(value) - errors = tuple(errors_iter) - if errors: - raise InvalidSchemaValue( - value, self.schema.type, schema_errors=errors) - - def unmarshal(self, value): - try: - return self.formatter.unmarshal(value) - except ValueError as exc: - raise InvalidSchemaFormatValue( - value, self.schema.format, exc) - - -class StringUnmarshaller(PrimitiveTypeUnmarshaller): - - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_string, None), text_type), - SchemaFormat.PASSWORD: Formatter.from_callables( - partial(oas30_format_checker.check, format='password'), text_type), - SchemaFormat.DATE: Formatter.from_callables( - partial(oas30_format_checker.check, format='date'), format_date), - SchemaFormat.DATETIME: Formatter.from_callables( - partial(oas30_format_checker.check, format='date-time'), - parse_datetime), - SchemaFormat.BINARY: Formatter.from_callables( - partial(oas30_format_checker.check, format='binary'), binary_type), - SchemaFormat.UUID: Formatter.from_callables( - partial(oas30_format_checker.check, format='uuid'), format_uuid), - SchemaFormat.BYTE: Formatter.from_callables( - partial(oas30_format_checker.check, format='byte'), format_byte), - } - - -class IntegerUnmarshaller(PrimitiveTypeUnmarshaller): - - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_integer, None), int), - SchemaFormat.INT32: Formatter.from_callables( - partial(oas30_format_checker.check, format='int32'), int), - SchemaFormat.INT64: Formatter.from_callables( - partial(oas30_format_checker.check, format='int64'), int), - } - - -class NumberUnmarshaller(PrimitiveTypeUnmarshaller): - - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_number, None), format_number), - SchemaFormat.FLOAT: Formatter.from_callables( - partial(oas30_format_checker.check, format='float'), float), - SchemaFormat.DOUBLE: Formatter.from_callables( - partial(oas30_format_checker.check, format='double'), float), - } - - -class BooleanUnmarshaller(PrimitiveTypeUnmarshaller): - - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_bool, None), forcebool), - } - - -class ComplexUnmarshaller(PrimitiveTypeUnmarshaller): - +class PrimitiveUnmarshaller: def __init__( - self, formatter, validator, schema, unmarshallers_factory, - context=None): - super(ComplexUnmarshaller, self).__init__(formatter, validator, schema) - self.unmarshallers_factory = unmarshallers_factory - self.context = context + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + schema_unmarshaller: "SchemaUnmarshaller", + ) -> None: + self.schema = schema + self.schema_validator = schema_validator + self.schema_unmarshaller = schema_unmarshaller + def __call__(self, value: Any) -> Any: + return value -class ArrayUnmarshaller(ComplexUnmarshaller): - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_array, None), list), - } +class ArrayUnmarshaller(PrimitiveUnmarshaller): + def __call__(self, value: Any) -> Optional[List[Any]]: + return list(map(self.items_unmarshaller.unmarshal, value)) @property - def items_unmarshaller(self): - return self.unmarshallers_factory.create(self.schema.items) + def items_unmarshaller(self) -> "SchemaUnmarshaller": + # sometimes we don't have any schema i.e. free-form objects + items_schema = self.schema.get("items", SchemaPath.from_dict({})) + return self.schema_unmarshaller.evolve(items_schema) - def __call__(self, value=NoValue): - value = super(ArrayUnmarshaller, self).__call__(value) - if value is None and self.schema.nullable: - return None - return list(map(self.items_unmarshaller, value)) +class ObjectUnmarshaller(PrimitiveUnmarshaller): + def __call__(self, value: Any) -> Any: + properties = self._unmarshal_properties(value) -class ObjectUnmarshaller(ComplexUnmarshaller): + fields: Iterable[str] = properties and properties.keys() or [] + object_class = self.object_class_factory.create(self.schema, fields) - FORMATTERS = { - SchemaFormat.NONE: Formatter.from_callables( - partial(is_object, None), dict), - } + return object_class(**properties) @property - def model_factory(self): - return ModelFactory() + def object_class_factory(self) -> ModelPathFactory: + return ModelPathFactory() - def unmarshal(self, value): - try: - value = self.formatter.unmarshal(value) - except ValueError as exc: - raise InvalidSchemaFormatValue( - value, self.schema.format, exc) - else: - return self._unmarshal_object(value) - - def _unmarshal_object(self, value=NoValue): - if self.schema.one_of: - properties = None - for one_of_schema in self.schema.one_of: - try: - unmarshalled = self._unmarshal_properties( - value, one_of_schema) - except (UnmarshalError, ValueError): - pass - else: - if properties is not None: - log.warning("multiple valid oneOf schemas found") - continue - properties = unmarshalled - - if properties is None: - log.warning("valid oneOf schema not found") - - else: - properties = self._unmarshal_properties(value) - - if 'x-model' in self.schema.extensions: - extension = self.schema.extensions['x-model'] - return self.model_factory.create(properties, name=extension.value) + def evolve(self, schema: SchemaPath) -> "ObjectUnmarshaller": + cls = self.__class__ - return properties - - def _unmarshal_properties(self, value=NoValue, one_of_schema=None): - all_props = self.schema.get_all_properties() - all_props_names = self.schema.get_all_properties_names() - - if one_of_schema is not None: - all_props.update(one_of_schema.get_all_properties()) - all_props_names |= one_of_schema.\ - get_all_properties_names() - - value_props_names = value.keys() - extra_props = set(value_props_names) - set(all_props_names) + return cls( + schema, + self.schema_validator.evolve(schema), + self.schema_unmarshaller, + ) + def _unmarshal_properties( + self, value: Any, schema_only: bool = False + ) -> Any: properties = {} - if isinstance(self.schema.additional_properties, Schema): - for prop_name in extra_props: - prop_value = value[prop_name] - properties[prop_name] = self.unmarshallers_factory.create( - self.schema.additional_properties)(prop_value) - elif self.schema.additional_properties is True: - for prop_name in extra_props: - prop_value = value[prop_name] - properties[prop_name] = prop_value - for prop_name, prop in iteritems(all_props): - if self.context == UnmarshalContext.REQUEST and prop.read_only: - continue - if self.context == UnmarshalContext.RESPONSE and prop.write_only: - continue + one_of_schema = self.schema_validator.get_one_of_schema(value) + if one_of_schema is not None: + one_of_properties = self.evolve( + one_of_schema + )._unmarshal_properties(value, schema_only=True) + properties.update(one_of_properties) + + any_of_schemas = self.schema_validator.iter_any_of_schemas(value) + for any_of_schema in any_of_schemas: + any_of_properties = self.evolve( + any_of_schema + )._unmarshal_properties(value, schema_only=True) + properties.update(any_of_properties) + + all_of_schemas = self.schema_validator.iter_all_of_schemas(value) + for all_of_schema in all_of_schemas: + all_of_properties = self.evolve( + all_of_schema + )._unmarshal_properties(value, schema_only=True) + properties.update(all_of_properties) + + for prop_name, prop_schema in get_properties(self.schema).items(): try: prop_value = value[prop_name] except KeyError: - if prop.default is NoValue: + if "default" not in prop_schema: continue - prop_value = prop.default - - properties[prop_name] = self.unmarshallers_factory.create( - prop)(prop_value) + prop_value = prop_schema["default"] + + properties[prop_name] = self.schema_unmarshaller.evolve( + prop_schema + ).unmarshal(prop_value) + + if schema_only: + return properties + + additional_properties = self.schema.getkey( + "additionalProperties", True + ) + if additional_properties is not False: + # free-form object + if additional_properties is True: + additional_prop_schema = SchemaPath.from_dict( + {"nullable": True} + ) + # defined schema + else: + additional_prop_schema = self.schema / "additionalProperties" + additional_prop_unmarshaler = self.schema_unmarshaller.evolve( + additional_prop_schema + ) + for prop_name, prop_value in value.items(): + if prop_name in properties: + continue + properties[prop_name] = additional_prop_unmarshaler.unmarshal( + prop_value + ) return properties -class AnyUnmarshaller(ComplexUnmarshaller): +class MultiTypeUnmarshaller(PrimitiveUnmarshaller): + def __call__(self, value: Any) -> Any: + primitive_type = self.schema_validator.get_primitive_type(value) + # OpenAPI 3.0: handle no type for None + if primitive_type is None: + return None + unmarshaller = self.schema_unmarshaller.get_type_unmarshaller( + primitive_type + ) + return unmarshaller(value) - FORMATTERS = { - SchemaFormat.NONE: Formatter(), - } - SCHEMA_TYPES_ORDER = [ - SchemaType.OBJECT, SchemaType.ARRAY, SchemaType.BOOLEAN, - SchemaType.INTEGER, SchemaType.NUMBER, SchemaType.STRING, - ] +class AnyUnmarshaller(MultiTypeUnmarshaller): + pass - def unmarshal(self, value=NoValue): - one_of_schema = self._get_one_of_schema(value) - if one_of_schema: - return self.unmarshallers_factory.create(one_of_schema)(value) - all_of_schema = self._get_all_of_schema(value) - if all_of_schema: - return self.unmarshallers_factory.create(all_of_schema)(value) +class TypesUnmarshaller: + unmarshallers: Mapping[str, Type[PrimitiveUnmarshaller]] = {} + multi: Optional[Type[PrimitiveUnmarshaller]] = None - for schema_type in self.SCHEMA_TYPES_ORDER: - unmarshaller = self.unmarshallers_factory.create( - self.schema, type_override=schema_type) - # validate with validator of formatter (usualy type validator) - try: - unmarshaller._formatter_validate(value) - except ValidateError: - continue - else: - return unmarshaller(value) + def __init__( + self, + unmarshallers: Mapping[str, Type[PrimitiveUnmarshaller]], + default: Type[PrimitiveUnmarshaller], + multi: Optional[Type[PrimitiveUnmarshaller]] = None, + ): + self.unmarshallers = unmarshallers + self.default = default + self.multi = multi + + def get_types(self) -> List[str]: + return list(self.unmarshallers.keys()) + + def get_unmarshaller_cls( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> Type["PrimitiveUnmarshaller"]: + if schema_type is None: + return self.default + if isinstance(schema_type, Iterable) and not isinstance( + schema_type, str + ): + if self.multi is None: + raise TypeError("Unmarshaller does not accept multiple types") + return self.multi + + return self.unmarshallers[schema_type] + + +class FormatsUnmarshaller: + def __init__( + self, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): + if format_unmarshallers is None: + format_unmarshallers = {} + self.format_unmarshallers = format_unmarshallers + if extra_format_unmarshallers is None: + extra_format_unmarshallers = {} + self.extra_format_unmarshallers = extra_format_unmarshallers + + def unmarshal(self, schema_format: str, value: Any) -> Any: + format_unmarshaller = self.get_unmarshaller(schema_format) + if format_unmarshaller is None: + return value + try: + return format_unmarshaller(value) + except (AttributeError, ValueError, TypeError): + return value + + def get_unmarshaller( + self, schema_format: str + ) -> Optional[FormatUnmarshaller]: + if schema_format in self.extra_format_unmarshallers: + return self.extra_format_unmarshallers[schema_format] + if schema_format in self.format_unmarshallers: + return self.format_unmarshallers[schema_format] + + return None + + def __contains__(self, schema_format: str) -> bool: + format_unmarshallers_dicts: List[Mapping[str, Any]] = [ + self.extra_format_unmarshallers, + self.format_unmarshallers, + ] + for content in format_unmarshallers_dicts: + if schema_format in content: + return True + return False + + +class SchemaUnmarshaller: + def __init__( + self, + schema: SchemaPath, + schema_validator: SchemaValidator, + types_unmarshaller: TypesUnmarshaller, + formats_unmarshaller: FormatsUnmarshaller, + ): + self.schema = schema + self.schema_validator = schema_validator - log.warning("failed to unmarshal any type") - return value + self.types_unmarshaller = types_unmarshaller + self.formats_unmarshaller = formats_unmarshaller - def _get_one_of_schema(self, value): - if not self.schema.one_of: - return - for subschema in self.schema.one_of: - unmarshaller = self.unmarshallers_factory.create(subschema) - try: - unmarshaller.validate(value) - except ValidateError: - continue - else: - return subschema + def unmarshal(self, value: Any) -> Any: + self.schema_validator.validate(value) - def _get_all_of_schema(self, value): - if not self.schema.all_of: - return - for subschema in self.schema.all_of: - if subschema.type == SchemaType.ANY: - continue - unmarshaller = self.unmarshallers_factory.create(subschema) - try: - unmarshaller.validate(value) - except ValidateError: + # skip unmarshalling for nullable in OpenAPI 3.0 + if value is None and self.schema.getkey("nullable", False): + return value + + schema_type = self.schema.getkey("type") + type_unmarshaller = self.get_type_unmarshaller(schema_type) + typed = type_unmarshaller(value) + # skip finding format for None + if typed is None: + return None + schema_format = self.find_format(value) + if schema_format is None: + return typed + # ignore incompatible formats + if not ( + isinstance(value, str) + or + # Workaround allows bytes for binary and byte formats + (isinstance(value, bytes) and schema_format in ["binary", "byte"]) + ): + return typed + + format_unmarshaller = self.get_format_unmarshaller(schema_format) + if format_unmarshaller is None: + return typed + try: + return format_unmarshaller(typed) + except (AttributeError, ValueError, TypeError): + return typed + + def get_type_unmarshaller( + self, + schema_type: Optional[Union[Iterable[str], str]], + ) -> PrimitiveUnmarshaller: + klass = self.types_unmarshaller.get_unmarshaller_cls(schema_type) + return klass( + self.schema, + self.schema_validator, + self, + ) + + def get_format_unmarshaller( + self, + schema_format: str, + ) -> Optional[FormatUnmarshaller]: + return self.formats_unmarshaller.get_unmarshaller(schema_format) + + def evolve(self, schema: SchemaPath) -> "SchemaUnmarshaller": + cls = self.__class__ + + return cls( + schema, + self.schema_validator.evolve(schema), + self.types_unmarshaller, + self.formats_unmarshaller, + ) + + def find_format(self, value: Any) -> Optional[str]: + for schema in self.schema_validator.iter_valid_schemas(value): + schema_validator = self.schema_validator.evolve(schema) + primitive_type = schema_validator.get_primitive_type(value) + if primitive_type != "string": continue - else: - return subschema + if "format" in schema: + return str(schema.getkey("format")) + return None diff --git a/openapi_core/unmarshalling/schemas/util.py b/openapi_core/unmarshalling/schemas/util.py index 66654caa..6efc8e60 100644 --- a/openapi_core/unmarshalling/schemas/util.py +++ b/openapi_core/unmarshalling/schemas/util.py @@ -1,50 +1,29 @@ """OpenAPI core schemas util module""" + from base64 import b64decode -from copy import copy -import datetime -from distutils.util import strtobool -from six import string_types, text_type, integer_types +from datetime import date +from datetime import datetime +from typing import Any +from typing import Union from uuid import UUID -from openapi_schema_validator import oas30_format_checker - -from openapi_core.compat import lru_cache - - -def forcebool(val): - if isinstance(val, string_types): - val = strtobool(val) - - return bool(val) +def format_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() -def format_date(value): - return datetime.datetime.strptime(value, '%Y-%m-%d').date() - -def format_uuid(value): +def format_uuid(value: Any) -> UUID: if isinstance(value, UUID): return value return UUID(value) -def format_byte(value, encoding='utf8'): - return text_type(b64decode(value), encoding) +def format_byte(value: str, encoding: str = "utf8") -> str: + return str(b64decode(value), encoding) -def format_number(value): - if isinstance(value, integer_types + (float, )): +def format_number(value: str) -> Union[int, float]: + if isinstance(value, (int, float)): return value return float(value) - - -@lru_cache() -def build_format_checker(**custom_formatters): - if not custom_formatters: - return oas30_format_checker - - fc = copy(oas30_format_checker) - for name, formatter in custom_formatters.items(): - fc.checks(name)(formatter.validate) - return fc diff --git a/openapi_core/unmarshalling/typing.py b/openapi_core/unmarshalling/typing.py new file mode 100644 index 00000000..587b977c --- /dev/null +++ b/openapi_core/unmarshalling/typing.py @@ -0,0 +1,12 @@ +from typing import Awaitable +from typing import Callable +from typing import Iterable + +from openapi_core.typing import ResponseType +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult + +ErrorsHandlerCallable = Callable[[Iterable[Exception]], ResponseType] +ValidRequestHandlerCallable = Callable[[RequestUnmarshalResult], ResponseType] +AsyncValidRequestHandlerCallable = Callable[ + [RequestUnmarshalResult], Awaitable[ResponseType] +] diff --git a/openapi_core/unmarshalling/unmarshallers.py b/openapi_core/unmarshalling/unmarshallers.py new file mode 100644 index 00000000..984b9ea1 --- /dev/null +++ b/openapi_core/unmarshalling/unmarshallers.py @@ -0,0 +1,118 @@ +from typing import Any +from typing import Mapping +from typing import Optional +from typing import Tuple + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.unmarshalling.schemas.datatypes import ( + FormatUnmarshallersDict, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory +from openapi_core.validation.validators import BaseValidator + + +class BaseUnmarshaller(BaseValidator): + schema_unmarshallers_factory: SchemaUnmarshallersFactory = NotImplemented + + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + schema_unmarshallers_factory: Optional[ + SchemaUnmarshallersFactory + ] = None, + format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, + ): + if schema_validators_factory is None and schema_unmarshallers_factory: + schema_validators_factory = ( + schema_unmarshallers_factory.schema_validators_factory + ) + BaseValidator.__init__( + self, + spec, + base_url=base_url, + style_deserializers_factory=style_deserializers_factory, + media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, + schema_validators_factory=schema_validators_factory, + path_finder_cls=path_finder_cls, + spec_validator_cls=spec_validator_cls, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + extra_media_type_deserializers=extra_media_type_deserializers, + ) + self.schema_unmarshallers_factory = ( + schema_unmarshallers_factory or self.schema_unmarshallers_factory + ) + if self.schema_unmarshallers_factory is NotImplemented: + raise NotImplementedError( + "schema_unmarshallers_factory is not assigned" + ) + self.format_unmarshallers = format_unmarshallers + self.extra_format_unmarshallers = extra_format_unmarshallers + + def _unmarshal_schema(self, schema: SchemaPath, value: Any) -> Any: + unmarshaller = self.schema_unmarshallers_factory.create( + schema, + format_validators=self.format_validators, + extra_format_validators=self.extra_format_validators, + format_unmarshallers=self.format_unmarshallers, + extra_format_unmarshallers=self.extra_format_unmarshallers, + ) + return unmarshaller.unmarshal(value) + + def _get_param_or_header_and_schema( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Tuple[Any, Optional[SchemaPath]]: + casted, schema = super()._get_param_or_header_and_schema( + param_or_header, location, name=name + ) + if schema is None: + return casted, None + return self._unmarshal_schema(schema, casted), schema + + def _get_content_and_schema( + self, raw: Any, content: SchemaPath, mimetype: Optional[str] = None + ) -> Tuple[Any, Optional[SchemaPath]]: + casted, schema = super()._get_content_and_schema( + raw, content, mimetype + ) + if schema is None: + return casted, None + return self._unmarshal_schema(schema, casted), schema diff --git a/openapi_core/util.py b/openapi_core/util.py new file mode 100644 index 00000000..d8c5da16 --- /dev/null +++ b/openapi_core/util.py @@ -0,0 +1,23 @@ +"""OpenAPI core util module""" + +from itertools import chain +from typing import Any +from typing import Iterable + + +def forcebool(val: Any) -> bool: + if isinstance(val, str): + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return True + elif val in ("n", "no", "f", "false", "off", "0"): + return False + else: + raise ValueError(f"invalid truth value {val!r}") + + return bool(val) + + +def chainiters(*lists: Iterable[Any]) -> Iterable[Any]: + iters = map(lambda l: l and iter(l) or [], lists) + return chain(*iters) diff --git a/openapi_core/validation/__init__.py b/openapi_core/validation/__init__.py index e69de29b..21c27dda 100644 --- a/openapi_core/validation/__init__.py +++ b/openapi_core/validation/__init__.py @@ -0,0 +1 @@ +"""OpenAPI core validation module""" diff --git a/openapi_core/validation/configurations.py b/openapi_core/validation/configurations.py new file mode 100644 index 00000000..ebc32fc4 --- /dev/null +++ b/openapi_core/validation/configurations.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from typing import Optional + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.security import security_provider_factory +from openapi_core.security.factories import SecurityProviderFactory +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@dataclass +class ValidatorConfig: + """Validator configuration dataclass. + + Attributes: + server_base_url + Server base URI. + path_finder_cls + Path finder class. + webhook_path_finder_cls + Webhook path finder class. + style_deserializers_factory + Style deserializers factory. + media_type_deserializers_factory + Media type deserializers factory. + schema_casters_factory + Schema casters factory. + schema_validators_factory + Schema validators factory. + extra_format_validators + Extra format validators. + extra_media_type_deserializers + Extra media type deserializers. + security_provider_factory + Security providers factory. + """ + + server_base_url: Optional[str] = None + path_finder_cls: Optional[PathFinderType] = None + webhook_path_finder_cls: Optional[PathFinderType] = None + + style_deserializers_factory: StyleDeserializersFactory = ( + style_deserializers_factory + ) + media_type_deserializers_factory: MediaTypeDeserializersFactory = ( + media_type_deserializers_factory + ) + schema_casters_factory: Optional[SchemaCastersFactory] = None + schema_validators_factory: Optional[SchemaValidatorsFactory] = None + + extra_format_validators: Optional[FormatValidatorsDict] = None + extra_media_type_deserializers: Optional[MediaTypeDeserializersDict] = None + + security_provider_factory: SecurityProviderFactory = ( + security_provider_factory + ) diff --git a/openapi_core/validation/datatypes.py b/openapi_core/validation/datatypes.py deleted file mode 100644 index 0bf86767..00000000 --- a/openapi_core/validation/datatypes.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OpenAPI core validation datatypes module""" -import attr - - -@attr.s -class BaseValidationResult(object): - errors = attr.ib(factory=list) - - def raise_for_errors(self): - for error in self.errors: - raise error diff --git a/openapi_core/validation/decorators.py b/openapi_core/validation/decorators.py index de5c2c2c..fbf50b5a 100644 --- a/openapi_core/validation/decorators.py +++ b/openapi_core/validation/decorators.py @@ -1,59 +1,58 @@ -"""OpenAPI core validation decorators module""" from functools import wraps +from inspect import signature +from typing import Any +from typing import Callable +from typing import Optional +from typing import Type -from openapi_core.validation.processors import OpenAPIProcessor +from openapi_core.exceptions import OpenAPIError +from openapi_core.validation.schemas.exceptions import ValidateError +OpenAPIErrorType = Type[OpenAPIError] -class OpenAPIDecorator(OpenAPIProcessor): +class ValidationErrorWrapper: def __init__( - self, - request_validator, - response_validator, - request_factory, - response_factory, - request_provider, - openapi_errors_handler, + self, + err_cls: OpenAPIErrorType, + err_validate_cls: Optional[OpenAPIErrorType] = None, + err_cls_init: Optional[str] = None, + **err_cls_kw: Any ): - super(OpenAPIDecorator, self).__init__( - request_validator, response_validator) - self.request_factory = request_factory - self.response_factory = response_factory - self.request_provider = request_provider - self.openapi_errors_handler = openapi_errors_handler - - def __call__(self, view): - @wraps(view) - def decorated(*args, **kwargs): - request = self._get_request(*args, **kwargs) - openapi_request = self._get_openapi_request(request) - request_result = self.process_request(openapi_request) - if request_result.errors: - return self._handle_request_errors(request_result) - response = self._handle_request_view( - request_result, view, *args, **kwargs) - openapi_response = self._get_openapi_response(response) - response_result = self.process_response( - openapi_request, openapi_response) - if response_result.errors: - return self._handle_response_errors(response_result) - return response - return decorated - - def _get_request(self, *args, **kwargs): - return self.request_provider.provide(*args, **kwargs) - - def _handle_request_view(self, request_result, view, *args, **kwargs): - return view(*args, **kwargs) - - def _handle_request_errors(self, request_result): - return self.openapi_errors_handler.handle(request_result.errors) - - def _handle_response_errors(self, response_result): - return self.openapi_errors_handler.handle(response_result.errors) - - def _get_openapi_request(self, request): - return self.request_factory.create(request) - - def _get_openapi_response(self, response): - return self.response_factory.create(response) + self.err_cls = err_cls + self.err_validate_cls = err_validate_cls or err_cls + self.err_cls_init = err_cls_init + self.err_cls_kw = err_cls_kw + + def __call__(self, f: Callable[..., Any]) -> Callable[..., Any]: + @wraps(f) + def wrapper(*args: Any, **kwds: Any) -> Any: + try: + return f(*args, **kwds) + except ValidateError as exc: + self._raise_error(exc, self.err_validate_cls, f, *args, **kwds) + except OpenAPIError as exc: + self._raise_error(exc, self.err_cls, f, *args, **kwds) + + return wrapper + + def _raise_error( + self, + exc: OpenAPIError, + cls: OpenAPIErrorType, + f: Callable[..., Any], + *args: Any, + **kwds: Any + ) -> None: + if isinstance(exc, self.err_cls): + raise + sig = signature(f) + ba = sig.bind(*args, **kwds) + kw = { + name: ba.arguments[func_kw] + for name, func_kw in self.err_cls_kw.items() + } + init = cls + if self.err_cls_init is not None: + init = getattr(cls, self.err_cls_init) + raise init(**kw) from exc diff --git a/openapi_core/validation/exceptions.py b/openapi_core/validation/exceptions.py index 1791fa0e..95b87cda 100644 --- a/openapi_core/validation/exceptions.py +++ b/openapi_core/validation/exceptions.py @@ -1,15 +1,11 @@ """OpenAPI core validation exceptions module""" -import attr + +from dataclasses import dataclass from openapi_core.exceptions import OpenAPIError +@dataclass class ValidationError(OpenAPIError): - pass - - -@attr.s(hash=True) -class InvalidSecurity(ValidationError): - - def __str__(self): - return "Security not valid for any requirement" + def __str__(self) -> str: + return f"{self.__class__.__name__}: {self.__cause__}" diff --git a/openapi_core/validation/integrations.py b/openapi_core/validation/integrations.py new file mode 100644 index 00000000..1926b932 --- /dev/null +++ b/openapi_core/validation/integrations.py @@ -0,0 +1,38 @@ +"""OpenAPI core unmarshalling processors module""" + +from typing import Generic + +from openapi_core.app import OpenAPI +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.typing import RequestType +from openapi_core.typing import ResponseType + + +class ValidationIntegration(Generic[RequestType, ResponseType]): + def __init__( + self, + openapi: OpenAPI, + ): + self.openapi = openapi + + def get_openapi_request(self, request: RequestType) -> Request: + raise NotImplementedError + + def get_openapi_response(self, response: ResponseType) -> Response: + raise NotImplementedError + + def validate_request(self, request: RequestType) -> None: + openapi_request = self.get_openapi_request(request) + self.openapi.validate_request( + openapi_request, + ) + + def validate_response( + self, + request: RequestType, + response: ResponseType, + ) -> None: + openapi_request = self.get_openapi_request(request) + openapi_response = self.get_openapi_response(response) + self.openapi.validate_response(openapi_request, openapi_response) diff --git a/openapi_core/validation/processors.py b/openapi_core/validation/processors.py index 3586146c..0fecc265 100644 --- a/openapi_core/validation/processors.py +++ b/openapi_core/validation/processors.py @@ -1,14 +1,15 @@ """OpenAPI core validation processors module""" +from openapi_core.typing import RequestType +from openapi_core.typing import ResponseType +from openapi_core.validation.integrations import ValidationIntegration -class OpenAPIProcessor(object): - def __init__(self, request_validator, response_validator): - self.request_validator = request_validator - self.response_validator = response_validator +class ValidationProcessor(ValidationIntegration[RequestType, ResponseType]): + def handle_request(self, request: RequestType) -> None: + self.validate_request(request) - def process_request(self, request): - return self.request_validator.validate(request) - - def process_response(self, request, response): - return self.response_validator.validate(request, response) + def handle_response( + self, request: RequestType, response: ResponseType + ) -> None: + self.validate_response(request, response) diff --git a/openapi_core/validation/request/__init__.py b/openapi_core/validation/request/__init__.py index e69de29b..fdde7767 100644 --- a/openapi_core/validation/request/__init__.py +++ b/openapi_core/validation/request/__init__.py @@ -0,0 +1,69 @@ +"""OpenAPI core validation request module""" + +from typing import Mapping + +from openapi_spec_validator.versions import consts as versions +from openapi_spec_validator.versions.datatypes import SpecVersion + +from openapi_core.validation.request.types import RequestValidatorType +from openapi_core.validation.request.types import WebhookRequestValidatorType +from openapi_core.validation.request.validators import V30RequestBodyValidator +from openapi_core.validation.request.validators import ( + V30RequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V30RequestSecurityValidator, +) +from openapi_core.validation.request.validators import V30RequestValidator +from openapi_core.validation.request.validators import V31RequestBodyValidator +from openapi_core.validation.request.validators import ( + V31RequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V31RequestSecurityValidator, +) +from openapi_core.validation.request.validators import V31RequestValidator +from openapi_core.validation.request.validators import ( + V31WebhookRequestBodyValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestSecurityValidator, +) +from openapi_core.validation.request.validators import ( + V31WebhookRequestValidator, +) + +__all__ = [ + "VALIDATORS", + "WEBHOOK_VALIDATORS", + "V30RequestBodyValidator", + "V30RequestParametersValidator", + "V30RequestSecurityValidator", + "V30RequestValidator", + "V31RequestBodyValidator", + "V31RequestParametersValidator", + "V31RequestSecurityValidator", + "V31RequestValidator", + "V31WebhookRequestBodyValidator", + "V31WebhookRequestParametersValidator", + "V31WebhookRequestSecurityValidator", + "V31WebhookRequestValidator", + "V3RequestValidator", + "V3WebhookRequestValidator", +] + +# versions mapping +VALIDATORS: Mapping[SpecVersion, RequestValidatorType] = { + versions.OPENAPIV30: V30RequestValidator, + versions.OPENAPIV31: V31RequestValidator, +} +WEBHOOK_VALIDATORS: Mapping[SpecVersion, WebhookRequestValidatorType] = { + versions.OPENAPIV31: V31WebhookRequestValidator, +} + +# alias to the latest v3 version +V3RequestValidator = V31RequestValidator +V3WebhookRequestValidator = V31WebhookRequestValidator diff --git a/openapi_core/validation/request/datatypes.py b/openapi_core/validation/request/datatypes.py index 1ad629cb..b673410a 100644 --- a/openapi_core/validation/request/datatypes.py +++ b/openapi_core/validation/request/datatypes.py @@ -1,68 +1,8 @@ -"""OpenAPI core validation request datatypes module""" -import attr -from werkzeug.datastructures import ImmutableMultiDict, Headers - -from openapi_core.validation.datatypes import BaseValidationResult - - -@attr.s -class RequestParameters(object): - """OpenAPI request parameters dataclass. - - Attributes: - query - Query string parameters as MultiDict. Must support getlist method. - header - Request headers as Headers. - cookie - Request cookies as dict. - path - Path parameters as dict. Gets resolved against spec if empty. - """ - query = attr.ib(factory=ImmutableMultiDict) - header = attr.ib(factory=Headers, converter=Headers) - cookie = attr.ib(factory=dict) - path = attr.ib(factory=dict) - - def __getitem__(self, location): - return getattr(self, location) - - -@attr.s -class OpenAPIRequest(object): - """OpenAPI request dataclass. - - Attributes: - full_url_pattern - The matched url with scheme, host and path pattern. - For example: - https://localhost:8000/api/v1/pets - https://localhost:8000/api/v1/pets/{pet_id} - method - The request method, as lowercase string. - parameters - A RequestParameters object. - body - The request body, as string. - mimetype - Like content type, but without parameters (eg, without charset, - type etc.) and always lowercase. - For example if the content type is "text/HTML; charset=utf-8" - the mimetype would be "text/html". - """ - - full_url_pattern = attr.ib() - method = attr.ib() - body = attr.ib() - mimetype = attr.ib() - parameters = attr.ib(factory=RequestParameters) - - -@attr.s -class RequestValidationResult(BaseValidationResult): - body = attr.ib(default=None) - parameters = attr.ib(factory=RequestParameters) - security = attr.ib(default=None) - server = attr.ib(default=None) - path = attr.ib(default=None) - operation = attr.ib(default=None) +from openapi_core.datatypes import Parameters +from openapi_core.datatypes import RequestParameters + +# Backward compatibility +__all__ = [ + "Parameters", + "RequestParameters", +] diff --git a/openapi_core/validation/request/exceptions.py b/openapi_core/validation/request/exceptions.py new file mode 100644 index 00000000..eb27a5c3 --- /dev/null +++ b/openapi_core/validation/request/exceptions.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from typing import Iterable + +from jsonschema_path import SchemaPath + +from openapi_core.datatypes import Parameters +from openapi_core.exceptions import OpenAPIError +from openapi_core.validation.exceptions import ValidationError +from openapi_core.validation.schemas.exceptions import ValidateError + + +@dataclass +class ParametersError(Exception): + parameters: Parameters + errors: Iterable[OpenAPIError] + + +class RequestValidationError(ValidationError): + """Request validation error""" + + +class RequestBodyValidationError(RequestValidationError): + def __str__(self) -> str: + return "Request body validation error" + + +class InvalidRequestBody(RequestBodyValidationError, ValidateError): + """Invalid request body""" + + +class MissingRequestBodyError(RequestBodyValidationError): + """Missing request body error""" + + +class MissingRequestBody(MissingRequestBodyError): + def __str__(self) -> str: + return "Missing request body" + + +class MissingRequiredRequestBody(MissingRequestBodyError): + def __str__(self) -> str: + return "Missing required request body" + + +@dataclass +class ParameterValidationError(RequestValidationError): + name: str + location: str + + @classmethod + def from_spec(cls, spec: SchemaPath) -> "ParameterValidationError": + return cls(spec["name"], spec["in"]) + + def __str__(self) -> str: + return f"{self.location.title()} parameter error: {self.name}" + + +class InvalidParameter(ParameterValidationError, ValidateError): + def __str__(self) -> str: + return f"Invalid {self.location} parameter: {self.name}" + + +class MissingParameterError(ParameterValidationError): + """Missing parameter error""" + + +class MissingParameter(MissingParameterError): + def __str__(self) -> str: + return f"Missing {self.location} parameter: {self.name}" + + +class MissingRequiredParameter(MissingParameterError): + def __str__(self) -> str: + return f"Missing required {self.location} parameter: {self.name}" + + +class SecurityValidationError(RequestValidationError): + pass + + +class InvalidSecurity(SecurityValidationError, ValidateError): + """Invalid security""" diff --git a/openapi_core/validation/request/protocols.py b/openapi_core/validation/request/protocols.py new file mode 100644 index 00000000..983864e2 --- /dev/null +++ b/openapi_core/validation/request/protocols.py @@ -0,0 +1,93 @@ +"""OpenAPI core validation request protocols module""" + +from typing import Iterator +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest +from openapi_core.security import security_provider_factory +from openapi_core.security.factories import SecurityProviderFactory +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@runtime_checkable +class RequestValidator(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + ): ... + + def iter_errors( + self, + request: Request, + ) -> Iterator[Exception]: ... + + def validate( + self, + request: Request, + ) -> None: ... + + +@runtime_checkable +class WebhookRequestValidator(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + ): ... + + def iter_errors( + self, + request: WebhookRequest, + ) -> Iterator[Exception]: ... + + def validate( + self, + request: WebhookRequest, + ) -> None: ... diff --git a/openapi_core/validation/request/shortcuts.py b/openapi_core/validation/request/shortcuts.py deleted file mode 100644 index 83ed4abb..00000000 --- a/openapi_core/validation/request/shortcuts.py +++ /dev/null @@ -1,70 +0,0 @@ -"""OpenAPI core validation request shortcuts module""" -import warnings - -from openapi_core.schema.media_types.exceptions import OpenAPIMediaTypeError -from openapi_core.schema.parameters.exceptions import OpenAPIParameterError -from openapi_core.schema.request_bodies.exceptions import ( - OpenAPIRequestBodyError, -) -from openapi_core.schema.schemas.exceptions import OpenAPISchemaError -from openapi_core.validation.request.validators import RequestValidator - -ERRORS_BODY = ( - OpenAPIRequestBodyError, OpenAPIMediaTypeError, OpenAPISchemaError, -) -ERRORS_PARAMETERS = ( - OpenAPIParameterError, -) - - -def validate_request(validator, request, failsafe=None): - if failsafe is None: - failsafe = () - result = validator.validate(request) - try: - result.raise_for_errors() - except failsafe: - pass - return result - - -def validate_parameters(validator, request): - warnings.warn( - "validate_parameters shortcut is deprecated, " - "use validator.validate instead", - DeprecationWarning, - ) - result = validator._validate_parameters(request) - result.raise_for_errors() - return result - - -def validate_body(validator, request): - warnings.warn( - "validate_body shortcut is deprecated, " - "use validator.validate instead", - DeprecationWarning, - ) - result = validator._validate_body(request) - result.raise_for_errors() - return result - - -def spec_validate_parameters(spec, request, request_factory=None): - if request_factory is not None: - request = request_factory(request) - - validator = RequestValidator(spec) - result = validate_parameters(validator, request) - - return result.parameters - - -def spec_validate_body(spec, request, request_factory=None): - if request_factory is not None: - request = request_factory(request) - - validator = RequestValidator(spec) - result = validate_body(validator, request) - - return result.body diff --git a/openapi_core/validation/request/types.py b/openapi_core/validation/request/types.py new file mode 100644 index 00000000..068e8cc6 --- /dev/null +++ b/openapi_core/validation/request/types.py @@ -0,0 +1,11 @@ +from typing import Type +from typing import Union + +from openapi_core.validation.request.protocols import RequestValidator +from openapi_core.validation.request.protocols import WebhookRequestValidator + +RequestValidatorType = Type[RequestValidator] +WebhookRequestValidatorType = Type[WebhookRequestValidator] +AnyRequestValidatorType = Union[ + RequestValidatorType, WebhookRequestValidatorType +] diff --git a/openapi_core/validation/request/validators.py b/openapi_core/validation/request/validators.py index 4e5c4af0..f2e1ae95 100644 --- a/openapi_core/validation/request/validators.py +++ b/openapi_core/validation/request/validators.py @@ -1,225 +1,474 @@ """OpenAPI core validation request validators module""" -from itertools import chain -from six import iteritems - -from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.schema.media_types.exceptions import InvalidContentType -from openapi_core.schema.parameters.exceptions import ( - MissingRequiredParameter, MissingParameter, + +import warnings +from typing import Any +from typing import Dict +from typing import Iterator +from typing import Optional + +from jsonschema_path import SchemaPath +from openapi_spec_validator import OpenAPIV30SpecValidator +from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas import oas30_write_schema_casters_factory +from openapi_core.casting.schemas import oas31_schema_casters_factory +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.datatypes import Parameters +from openapi_core.datatypes import RequestParameters +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, ) -from openapi_core.schema.request_bodies.exceptions import MissingRequestBody -from openapi_core.security.exceptions import SecurityError +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import BaseRequest +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest +from openapi_core.security import security_provider_factory +from openapi_core.security.exceptions import SecurityProviderError +from openapi_core.security.factories import SecurityProviderFactory from openapi_core.templating.paths.exceptions import PathError -from openapi_core.unmarshalling.schemas.enums import UnmarshalContext -from openapi_core.unmarshalling.schemas.exceptions import ( - UnmarshalError, ValidateError, +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.util import chainiters +from openapi_core.validation.decorators import ValidationErrorWrapper +from openapi_core.validation.request.exceptions import InvalidParameter +from openapi_core.validation.request.exceptions import InvalidRequestBody +from openapi_core.validation.request.exceptions import InvalidSecurity +from openapi_core.validation.request.exceptions import MissingParameter +from openapi_core.validation.request.exceptions import MissingRequestBody +from openapi_core.validation.request.exceptions import MissingRequiredParameter +from openapi_core.validation.request.exceptions import ( + MissingRequiredRequestBody, +) +from openapi_core.validation.request.exceptions import ParametersError +from openapi_core.validation.request.exceptions import ParameterValidationError +from openapi_core.validation.request.exceptions import ( + RequestBodyValidationError, ) -from openapi_core.validation.exceptions import InvalidSecurity -from openapi_core.validation.request.datatypes import ( - RequestParameters, RequestValidationResult, +from openapi_core.validation.request.exceptions import SecurityValidationError +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, ) +from openapi_core.validation.schemas import oas31_schema_validators_factory +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory +from openapi_core.validation.validators import BaseAPICallValidator from openapi_core.validation.validators import BaseValidator +from openapi_core.validation.validators import BaseWebhookValidator + + +class BaseRequestValidator(BaseValidator): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + security_provider_factory: SecurityProviderFactory = security_provider_factory, + ): + + BaseValidator.__init__( + self, + spec, + base_url=base_url, + style_deserializers_factory=style_deserializers_factory, + media_type_deserializers_factory=media_type_deserializers_factory, + schema_casters_factory=schema_casters_factory, + schema_validators_factory=schema_validators_factory, + path_finder_cls=path_finder_cls, + spec_validator_cls=spec_validator_cls, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + extra_media_type_deserializers=extra_media_type_deserializers, + ) + self.security_provider_factory = security_provider_factory - -class RequestValidator(BaseValidator): - - def validate(self, request): + def _iter_errors( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> Iterator[Exception]: try: - path, operation, _, path_result, _ = self._find_path(request) - # don't process if operation errors - except PathError as exc: - return RequestValidationResult(errors=[exc, ]) + self._get_security(request.parameters, operation) + # don't process if security errors + except SecurityValidationError as exc: + yield exc + return try: - security = self._get_security(request, operation) - except InvalidSecurity as exc: - return RequestValidationResult(errors=[exc, ]) - - request.parameters.path = request.parameters.path or \ - path_result.variables - params, params_errors = self._get_parameters( - request, chain( - iteritems(operation.parameters), - iteritems(path.parameters) - ) - ) - - body, body_errors = self._get_body(request, operation) + self._get_parameters(request.parameters, operation, path) + except ParametersError as exc: + yield from exc.errors - errors = params_errors + body_errors - return RequestValidationResult( - errors=errors, - body=body, - parameters=params, - security=security, - ) + try: + self._get_body(request.body, request.content_type, operation) + except MissingRequestBody: + pass + except RequestBodyValidationError as exc: + yield exc + + def _iter_body_errors( + self, request: BaseRequest, operation: SchemaPath + ) -> Iterator[Exception]: + try: + self._get_body(request.body, request.content_type, operation) + except RequestBodyValidationError as exc: + yield exc - def _validate_parameters(self, request): + def _iter_parameters_errors( + self, request: BaseRequest, operation: SchemaPath, path: SchemaPath + ) -> Iterator[Exception]: try: - path, operation, _, path_result, _ = self._find_path(request) - except PathError as exc: - return RequestValidationResult(errors=[exc, ]) - - request.parameters.path = request.parameters.path or \ - path_result.variables - params, params_errors = self._get_parameters( - request, chain( - iteritems(operation.parameters), - iteritems(path.parameters) - ) - ) - return RequestValidationResult( - errors=params_errors, - parameters=params, - ) + self._get_parameters(request.parameters, path, operation) + except ParametersError as exc: + yield from exc.errors - def _validate_body(self, request): + def _iter_security_errors( + self, request: BaseRequest, operation: SchemaPath + ) -> Iterator[Exception]: try: - _, operation, _, _, _ = self._find_path(request) - except PathError as exc: - return RequestValidationResult(errors=[exc, ]) + self._get_security(request.parameters, operation) + except SecurityValidationError as exc: + yield exc + + def _get_parameters( + self, + parameters: RequestParameters, + operation: SchemaPath, + path: SchemaPath, + ) -> Parameters: + operation_params = operation.get("parameters", []) + path_params = path.get("parameters", []) - body, body_errors = self._get_body(request, operation) - return RequestValidationResult( - errors=body_errors, - body=body, - ) + errors = [] + seen = set() + validated = Parameters() + params_iter = chainiters(operation_params, path_params) + for param in params_iter: + param_name = param["name"] + param_location = param["in"] + if (param_name, param_location) in seen: + # skip parameter already seen + # e.g. overriden path item paremeter on operation + continue + seen.add((param_name, param_location)) + try: + value = self._get_parameter(parameters, param) + except MissingParameter: + continue + except ParameterValidationError as exc: + errors.append(exc) + continue + else: + location = getattr(validated, param_location) + location[param_name] = value + + if errors: + raise ParametersError(errors=errors, parameters=validated) + + return validated + + @ValidationErrorWrapper( + ParameterValidationError, + InvalidParameter, + "from_spec", + spec="param", + ) + def _get_parameter( + self, parameters: RequestParameters, param: SchemaPath + ) -> Any: + name = param["name"] + deprecated = param.getkey("deprecated", False) + if deprecated: + warnings.warn( + f"{name} parameter is deprecated", + DeprecationWarning, + ) - def _get_security(self, request, operation): - security = self.spec.security - if operation.security is not None: - security = operation.security + param_location = param["in"] + location = parameters[param_location] + + try: + value, _ = self._get_param_or_header_and_schema(param, location) + except KeyError: + required = param.getkey("required", False) + if required: + raise MissingRequiredParameter(name, param_location) + raise MissingParameter(name, param_location) + else: + return value + + @ValidationErrorWrapper(SecurityValidationError, InvalidSecurity) + def _get_security( + self, parameters: RequestParameters, operation: SchemaPath + ) -> Optional[Dict[str, str]]: + security = None + if "security" in self.spec: + security = self.spec / "security" + if "security" in operation: + security = operation / "security" if not security: return {} + schemes = [] for security_requirement in security: try: + scheme_names = list(security_requirement.keys()) + schemes.append(scheme_names) return { scheme_name: self._get_security_value( - scheme_name, request) - for scheme_name in security_requirement + parameters, scheme_name + ) + for scheme_name in scheme_names } - except SecurityError: + except SecurityProviderError: continue - raise InvalidSecurity() + raise SecurityNotFound(schemes) - def _get_parameters(self, request, params): - errors = [] - seen = set() - locations = {} - for param_name, param in params: - if (param_name, param.location.value) in seen: - # skip parameter already seen - # e.g. overriden path item paremeter on operation - continue - seen.add((param_name, param.location.value)) - try: - raw_value = self._get_parameter_value(param, request) - except MissingRequiredParameter as exc: - errors.append(exc) - continue - except MissingParameter: - if not param.schema or not param.schema.has_default(): - continue - casted = param.schema.default - else: - try: - deserialised = self._deserialise_parameter( - param, raw_value) - except DeserializeError as exc: - errors.append(exc) - continue - - try: - casted = self._cast(param, deserialised) - except CastError as exc: - errors.append(exc) - continue + def _get_security_value( + self, parameters: RequestParameters, scheme_name: str + ) -> Any: + security_schemes = self.spec / "components#securitySchemes" + if scheme_name not in security_schemes: + return + scheme = security_schemes[scheme_name] + security_provider = self.security_provider_factory.create(scheme) + return security_provider(parameters) - try: - unmarshalled = self._unmarshal(param, casted) - except (ValidateError, UnmarshalError) as exc: - errors.append(exc) - else: - locations.setdefault(param.location.value, {}) - locations[param.location.value][param_name] = unmarshalled + @ValidationErrorWrapper(RequestBodyValidationError, InvalidRequestBody) + def _get_body( + self, body: Optional[bytes], mimetype: str, operation: SchemaPath + ) -> Any: + if "requestBody" not in operation: + return None - return RequestParameters(**locations), errors + # TODO: implement required flag checking + request_body = operation / "requestBody" + content = request_body / "content" - def _get_body(self, request, operation): - if operation.request_body is None: - return None, [] + raw_body = self._get_body_value(body, request_body) + value, _ = self._get_content_and_schema(raw_body, content, mimetype) + return value - try: - media_type = operation.request_body[request.mimetype] - except InvalidContentType as exc: - return None, [exc, ] + def _get_body_value( + self, body: Optional[bytes], request_body: SchemaPath + ) -> bytes: + if not body: + if request_body.getkey("required", False): + raise MissingRequiredRequestBody + raise MissingRequestBody + return body + + +class BaseAPICallRequestValidator(BaseRequestValidator, BaseAPICallValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: + raise NotImplementedError + + def validate(self, request: Request) -> None: + for err in self.iter_errors(request): + raise err + +class BaseWebhookRequestValidator(BaseRequestValidator, BaseWebhookValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + raise NotImplementedError + + def validate(self, request: WebhookRequest) -> None: + for err in self.iter_errors(request): + raise err + + +class APICallRequestBodyValidator(BaseAPICallRequestValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: try: - raw_body = self._get_body_value(operation.request_body, request) - except MissingRequestBody as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + yield exc + return + + yield from self._iter_body_errors(request, operation) + +class APICallRequestParametersValidator(BaseAPICallRequestValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: try: - deserialised = self._deserialise_media_type(media_type, raw_body) - except DeserializeError as exc: - return None, [exc, ] + path, operation, _, path_result, _ = self._find_path(request) + except PathError as exc: + yield exc + return + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + yield from self._iter_parameters_errors(request, operation, path) + + +class APICallRequestSecurityValidator(BaseAPICallRequestValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: try: - casted = self._cast(media_type, deserialised) - except CastError as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + yield exc + return + + yield from self._iter_security_errors(request, operation) + +class APICallRequestValidator(BaseAPICallRequestValidator): + def iter_errors(self, request: Request) -> Iterator[Exception]: try: - body = self._unmarshal(media_type, casted) - except (ValidateError, UnmarshalError) as exc: - return None, [exc, ] + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc + return - return body, [] + request.parameters.path = ( + request.parameters.path or path_result.variables + ) + + yield from self._iter_errors(request, operation, path) - def _get_security_value(self, scheme_name, request): - scheme = self.spec.components.security_schemes.get(scheme_name) - if not scheme: + +class WebhookRequestValidator(BaseWebhookRequestValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + try: + path, operation, _, path_result, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc return - from openapi_core.security.factories import SecurityProviderFactory - security_provider_factory = SecurityProviderFactory() - security_provider = security_provider_factory.create(scheme) - return security_provider(request) + request.parameters.path = ( + request.parameters.path or path_result.variables + ) - def _get_parameter_value(self, param, request): - location = request.parameters[param.location.value] + yield from self._iter_errors(request, operation, path) - if param.name not in location: - if param.required: - raise MissingRequiredParameter(param.name) - raise MissingParameter(param.name) +class WebhookRequestBodyValidator(BaseWebhookRequestValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + try: + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + yield exc + return - if param.aslist and param.explode: - if hasattr(location, 'getall'): - return location.getall(param.name) - return location.getlist(param.name) + yield from self._iter_body_errors(request, operation) - return location[param.name] - def _get_body_value(self, request_body, request): - if not request.body and request_body.required: - raise MissingRequestBody(request) - return request.body +class WebhookRequestParametersValidator(BaseWebhookRequestValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + try: + path, operation, _, path_result, _ = self._find_path(request) + except PathError as exc: + yield exc + return - def _deserialise_parameter(self, param, value): - from openapi_core.deserializing.parameters.factories import ( - ParameterDeserializersFactory, + request.parameters.path = ( + request.parameters.path or path_result.variables ) - deserializers_factory = ParameterDeserializersFactory() - deserializer = deserializers_factory.create(param) - return deserializer(value) - def _unmarshal(self, param_or_media_type, value): - return super(RequestValidator, self)._unmarshal( - param_or_media_type, value, context=UnmarshalContext.REQUEST, - ) + yield from self._iter_parameters_errors(request, operation, path) + + +class WebhookRequestSecurityValidator(BaseWebhookRequestValidator): + def iter_errors(self, request: WebhookRequest) -> Iterator[Exception]: + try: + _, operation, _, _, _ = self._find_path(request) + except PathError as exc: + yield exc + return + + yield from self._iter_security_errors(request, operation) + + +class V30RequestBodyValidator(APICallRequestBodyValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory + schema_validators_factory = oas30_write_schema_validators_factory + + +class V30RequestParametersValidator(APICallRequestParametersValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory + schema_validators_factory = oas30_write_schema_validators_factory + + +class V30RequestSecurityValidator(APICallRequestSecurityValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory + schema_validators_factory = oas30_write_schema_validators_factory + + +class V30RequestValidator(APICallRequestValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_write_schema_casters_factory + schema_validators_factory = oas30_write_schema_validators_factory + + +class V31RequestBodyValidator(APICallRequestBodyValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31RequestParametersValidator(APICallRequestParametersValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31RequestSecurityValidator(APICallRequestSecurityValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31RequestValidator(APICallRequestValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookRequestBodyValidator(WebhookRequestBodyValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookRequestParametersValidator(WebhookRequestParametersValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookRequestSecurityValidator(WebhookRequestSecurityValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookRequestValidator(WebhookRequestValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory diff --git a/openapi_core/validation/response/__init__.py b/openapi_core/validation/response/__init__.py index e69de29b..94694360 100644 --- a/openapi_core/validation/response/__init__.py +++ b/openapi_core/validation/response/__init__.py @@ -0,0 +1,61 @@ +"""OpenAPI core validation response module""" + +from typing import Mapping + +from openapi_spec_validator.versions import consts as versions +from openapi_spec_validator.versions.datatypes import SpecVersion + +from openapi_core.validation.response.types import ResponseValidatorType +from openapi_core.validation.response.types import WebhookResponseValidatorType +from openapi_core.validation.response.validators import ( + V30ResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V30ResponseHeadersValidator, +) +from openapi_core.validation.response.validators import V30ResponseValidator +from openapi_core.validation.response.validators import ( + V31ResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V31ResponseHeadersValidator, +) +from openapi_core.validation.response.validators import V31ResponseValidator +from openapi_core.validation.response.validators import ( + V31WebhookResponseDataValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseHeadersValidator, +) +from openapi_core.validation.response.validators import ( + V31WebhookResponseValidator, +) + +__all__ = [ + "VALIDATORS", + "WEBHOOK_VALIDATORS", + "V30ResponseDataValidator", + "V30ResponseHeadersValidator", + "V30ResponseValidator", + "V31ResponseDataValidator", + "V31ResponseHeadersValidator", + "V31ResponseValidator", + "V31WebhookResponseDataValidator", + "V31WebhookResponseHeadersValidator", + "V31WebhookResponseValidator", + "V3ResponseValidator", + "V3WebhookResponseValidator", +] + +# versions mapping +VALIDATORS: Mapping[SpecVersion, ResponseValidatorType] = { + versions.OPENAPIV30: V30ResponseValidator, + versions.OPENAPIV31: V31ResponseValidator, +} +WEBHOOK_VALIDATORS: Mapping[SpecVersion, WebhookResponseValidatorType] = { + versions.OPENAPIV31: V31WebhookResponseValidator, +} + +# alias to the latest v3 version +V3ResponseValidator = V31ResponseValidator +V3WebhookResponseValidator = V31WebhookResponseValidator diff --git a/openapi_core/validation/response/datatypes.py b/openapi_core/validation/response/datatypes.py deleted file mode 100644 index f55fc170..00000000 --- a/openapi_core/validation/response/datatypes.py +++ /dev/null @@ -1,29 +0,0 @@ -"""OpenAPI core validation response datatypes module""" -import attr - -from openapi_core.validation.datatypes import BaseValidationResult - - -@attr.s -class OpenAPIResponse(object): - """OpenAPI request dataclass. - - Attributes: - data - The response body, as string. - status_code - The status code as integer. - mimetype - Lowercase content type without charset. - """ - - data = attr.ib() - status_code = attr.ib() - - mimetype = attr.ib() - - -@attr.s -class ResponseValidationResult(BaseValidationResult): - data = attr.ib(default=None) - headers = attr.ib(factory=dict) diff --git a/openapi_core/validation/response/exceptions.py b/openapi_core/validation/response/exceptions.py new file mode 100644 index 00000000..ffd75c3f --- /dev/null +++ b/openapi_core/validation/response/exceptions.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import Any +from typing import Dict +from typing import Iterable + +from openapi_core.exceptions import OpenAPIError +from openapi_core.validation.exceptions import ValidationError +from openapi_core.validation.schemas.exceptions import ValidateError + + +@dataclass +class HeadersError(Exception): + headers: Dict[str, Any] + context: Iterable[OpenAPIError] + + +class ResponseValidationError(ValidationError): + """Response error""" + + +class DataValidationError(ResponseValidationError): + """Data error""" + + +class InvalidData(DataValidationError, ValidateError): + """Invalid data""" + + +class MissingData(DataValidationError): + def __str__(self) -> str: + return "Missing response data" + + +@dataclass +class HeaderValidationError(ResponseValidationError): + name: str + + +class InvalidHeader(HeaderValidationError, ValidateError): + """Invalid header""" + + +class MissingHeaderError(HeaderValidationError): + """Missing header error""" + + +class MissingHeader(MissingHeaderError): + def __str__(self) -> str: + return f"Missing header (without default value): {self.name}" + + +class MissingRequiredHeader(MissingHeaderError): + def __str__(self) -> str: + return f"Missing required header: {self.name}" diff --git a/openapi_core/validation/response/protocols.py b/openapi_core/validation/response/protocols.py new file mode 100644 index 00000000..f0f33dc6 --- /dev/null +++ b/openapi_core/validation/response/protocols.py @@ -0,0 +1,94 @@ +"""OpenAPI core validation response protocols module""" + +from typing import Iterator +from typing import Optional +from typing import Protocol +from typing import runtime_checkable + +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@runtime_checkable +class ResponseValidator(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + ): ... + + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: ... + + def validate( + self, + request: Request, + response: Response, + ) -> None: ... + + +@runtime_checkable +class WebhookResponseValidator(Protocol): + def __init__( + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, + ): ... + + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: ... + + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> None: ... diff --git a/openapi_core/validation/response/shortcuts.py b/openapi_core/validation/response/shortcuts.py deleted file mode 100644 index 7ea19f22..00000000 --- a/openapi_core/validation/response/shortcuts.py +++ /dev/null @@ -1,36 +0,0 @@ -"""OpenAPI core validation response shortcuts module""" -import warnings - -from openapi_core.validation.response.validators import ResponseValidator - - -def validate_response(validator, request, response): - result = validator.validate(request, response) - result.raise_for_errors() - return result - - -def validate_data(validator, request, response): - warnings.warn( - "validate_data shortcut is deprecated, " - "use validator.validate instead", - DeprecationWarning, - ) - result = validator._validate_data(request, response) - result.raise_for_errors() - return result - - -def spec_validate_data( - spec, request, response, - request_factory=None, - response_factory=None): - if request_factory is not None: - request = request_factory(request) - if response_factory is not None: - response = response_factory(response) - - validator = ResponseValidator(spec) - result = validate_data(validator, request, response) - - return result.data diff --git a/openapi_core/validation/response/types.py b/openapi_core/validation/response/types.py new file mode 100644 index 00000000..3446dd4d --- /dev/null +++ b/openapi_core/validation/response/types.py @@ -0,0 +1,11 @@ +from typing import Type +from typing import Union + +from openapi_core.validation.response.protocols import ResponseValidator +from openapi_core.validation.response.protocols import WebhookResponseValidator + +ResponseValidatorType = Type[ResponseValidator] +WebhookResponseValidatorType = Type[WebhookResponseValidator] +AnyResponseValidatorType = Union[ + ResponseValidatorType, WebhookResponseValidatorType +] diff --git a/openapi_core/validation/response/validators.py b/openapi_core/validation/response/validators.py index 10acdc93..cd4d4350 100644 --- a/openapi_core/validation/response/validators.py +++ b/openapi_core/validation/response/validators.py @@ -1,116 +1,407 @@ """OpenAPI core validation response validators module""" -from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.schema.media_types.exceptions import InvalidContentType -from openapi_core.schema.responses.exceptions import ( - InvalidResponse, MissingResponseContent, -) + +import warnings +from typing import Any +from typing import Dict +from typing import Iterator +from typing import List +from typing import Mapping +from typing import Optional + +from jsonschema_path import SchemaPath +from openapi_spec_validator import OpenAPIV30SpecValidator +from openapi_spec_validator import OpenAPIV31SpecValidator + +from openapi_core.casting.schemas import oas30_read_schema_casters_factory +from openapi_core.casting.schemas import oas31_schema_casters_factory +from openapi_core.exceptions import OpenAPIError +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest from openapi_core.templating.paths.exceptions import PathError -from openapi_core.unmarshalling.schemas.enums import UnmarshalContext -from openapi_core.unmarshalling.schemas.exceptions import ( - UnmarshalError, ValidateError, +from openapi_core.templating.responses.exceptions import ResponseFinderError +from openapi_core.validation.decorators import ValidationErrorWrapper +from openapi_core.validation.exceptions import ValidationError +from openapi_core.validation.response.exceptions import DataValidationError +from openapi_core.validation.response.exceptions import HeadersError +from openapi_core.validation.response.exceptions import HeaderValidationError +from openapi_core.validation.response.exceptions import InvalidData +from openapi_core.validation.response.exceptions import InvalidHeader +from openapi_core.validation.response.exceptions import MissingData +from openapi_core.validation.response.exceptions import MissingHeader +from openapi_core.validation.response.exceptions import MissingRequiredHeader +from openapi_core.validation.schemas import ( + oas30_read_schema_validators_factory, ) -from openapi_core.validation.response.datatypes import ResponseValidationResult +from openapi_core.validation.schemas import oas31_schema_validators_factory +from openapi_core.validation.validators import BaseAPICallValidator from openapi_core.validation.validators import BaseValidator +from openapi_core.validation.validators import BaseWebhookValidator + +class BaseResponseValidator(BaseValidator): + def _iter_errors( + self, + status_code: int, + data: Optional[bytes], + headers: Mapping[str, Any], + mimetype: str, + operation: SchemaPath, + ) -> Iterator[Exception]: + try: + operation_response = self._find_operation_response( + status_code, operation + ) + # don't process if operation errors + except ResponseFinderError as exc: + yield exc + return -class ResponseValidator(BaseValidator): + try: + self._get_data(data, mimetype, operation_response) + except DataValidationError as exc: + yield exc - def validate(self, request, response): try: - _, operation, _, _, _ = self._find_path(request) + self._get_headers(headers, operation_response) + except HeadersError as exc: + yield from exc.context + + def _iter_data_errors( + self, + status_code: int, + data: Optional[bytes], + mimetype: str, + operation: SchemaPath, + ) -> Iterator[Exception]: + try: + operation_response = self._find_operation_response( + status_code, operation + ) # don't process if operation errors - except PathError as exc: - return ResponseValidationResult(errors=[exc, ]) + except ResponseFinderError as exc: + yield exc + return try: - operation_response = self._get_operation_response( - operation, response) + self._get_data(data, mimetype, operation_response) + except DataValidationError as exc: + yield exc + + def _iter_headers_errors( + self, + status_code: int, + headers: Mapping[str, Any], + operation: SchemaPath, + ) -> Iterator[Exception]: + try: + operation_response = self._find_operation_response( + status_code, operation + ) # don't process if operation errors - except InvalidResponse as exc: - return ResponseValidationResult(errors=[exc, ]) + except ResponseFinderError as exc: + yield exc + return - data, data_errors = self._get_data(response, operation_response) + try: + self._get_headers(headers, operation_response) + except HeadersError as exc: + yield from exc.context - headers, headers_errors = self._get_headers( - response, operation_response) + def _find_operation_response( + self, + status_code: int, + operation: SchemaPath, + ) -> SchemaPath: + from openapi_core.templating.responses.finders import ResponseFinder - errors = data_errors + headers_errors - return ResponseValidationResult( - errors=errors, - data=data, - headers=headers, - ) + finder = ResponseFinder(operation / "responses") + return finder.find(str(status_code)) + + @ValidationErrorWrapper(DataValidationError, InvalidData) + def _get_data( + self, + data: Optional[bytes], + mimetype: str, + operation_response: SchemaPath, + ) -> Any: + if "content" not in operation_response: + return None + + content = operation_response / "content" + + raw_data = self._get_data_value(data) + value, _ = self._get_content_and_schema(raw_data, content, mimetype) + return value - def _get_operation_response(self, operation, response): - return operation.get_response(str(response.status_code)) + def _get_data_value(self, data: Optional[bytes]) -> bytes: + if not data: + raise MissingData + + return data + + def _get_headers( + self, headers: Mapping[str, Any], operation_response: SchemaPath + ) -> Dict[str, Any]: + if "headers" not in operation_response: + return {} + + response_headers = operation_response / "headers" + + errors: List[OpenAPIError] = [] + validated: Dict[str, Any] = {} + for name, header in list(response_headers.items()): + # ignore Content-Type header + if name.lower() == "content-type": + continue + try: + value = self._get_header(headers, name, header) + except MissingHeader: + continue + except ValidationError as exc: + errors.append(exc) + continue + else: + validated[name] = value + + if errors: + raise HeadersError(context=iter(errors), headers=validated) + + return validated + + @ValidationErrorWrapper(HeaderValidationError, InvalidHeader, name="name") + def _get_header( + self, headers: Mapping[str, Any], name: str, header: SchemaPath + ) -> Any: + deprecated = header.getkey("deprecated", False) + if deprecated: + warnings.warn( + f"{name} header is deprecated", + DeprecationWarning, + ) - def _validate_data(self, request, response): + try: + value, _ = self._get_param_or_header_and_schema( + header, headers, name=name + ) + except KeyError: + required = header.getkey("required", False) + if required: + raise MissingRequiredHeader(name) + raise MissingHeader(name) + else: + return value + + +class BaseAPICallResponseValidator( + BaseResponseValidator, BaseAPICallValidator +): + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: + raise NotImplementedError + + def validate( + self, + request: Request, + response: Response, + ) -> None: + for err in self.iter_errors(request, response): + raise err + + +class BaseWebhookResponseValidator( + BaseResponseValidator, BaseWebhookValidator +): + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: + raise NotImplementedError + + def validate( + self, + request: WebhookRequest, + response: Response, + ) -> None: + for err in self.iter_errors(request, response): + raise err + + +class APICallResponseDataValidator(BaseAPICallResponseValidator): + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: try: _, operation, _, _, _ = self._find_path(request) # don't process if operation errors except PathError as exc: - return ResponseValidationResult(errors=[exc, ]) + yield exc + return + + yield from self._iter_data_errors( + response.status_code, + response.data, + response.content_type, + operation, + ) + +class APICallResponseHeadersValidator(BaseAPICallResponseValidator): + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: try: - operation_response = self._get_operation_response( - operation, response) + _, operation, _, _, _ = self._find_path(request) # don't process if operation errors - except InvalidResponse as exc: - return ResponseValidationResult(errors=[exc, ]) + except PathError as exc: + yield exc + return - data, data_errors = self._get_data(response, operation_response) - return ResponseValidationResult( - errors=data_errors, - data=data, + yield from self._iter_headers_errors( + response.status_code, response.headers, operation ) - def _get_data(self, response, operation_response): - if not operation_response.content: - return None, [] +class APICallResponseValidator(BaseAPICallResponseValidator): + def iter_errors( + self, + request: Request, + response: Response, + ) -> Iterator[Exception]: try: - media_type = operation_response[response.mimetype] - except InvalidContentType as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc + return + + yield from self._iter_errors( + response.status_code, + response.data, + response.headers, + response.content_type, + operation, + ) - try: - raw_data = self._get_data_value(response) - except MissingResponseContent as exc: - return None, [exc, ] +class WebhookResponseDataValidator(BaseWebhookResponseValidator): + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: try: - deserialised = self._deserialise_media_type(media_type, raw_data) - except DeserializeError as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc + return + + yield from self._iter_data_errors( + response.status_code, + response.data, + response.content_type, + operation, + ) + +class WebhookResponseHeadersValidator(BaseWebhookResponseValidator): + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: try: - casted = self._cast(media_type, deserialised) - except CastError as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc + return + + yield from self._iter_headers_errors( + response.status_code, response.headers, operation + ) + +class WebhookResponseValidator(BaseWebhookResponseValidator): + def iter_errors( + self, + request: WebhookRequest, + response: Response, + ) -> Iterator[Exception]: try: - data = self._unmarshal(media_type, casted) - except (ValidateError, UnmarshalError) as exc: - return None, [exc, ] + _, operation, _, _, _ = self._find_path(request) + # don't process if operation errors + except PathError as exc: + yield exc + return - return data, [] + yield from self._iter_errors( + response.status_code, + response.data, + response.headers, + response.content_type, + operation, + ) - def _get_headers(self, response, operation_response): - errors = [] - # @todo: implement - headers = {} +class V30ResponseDataValidator(APICallResponseDataValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory + schema_validators_factory = oas30_read_schema_validators_factory - return headers, errors - def _get_data_value(self, response): - if not response.data: - raise MissingResponseContent(response) +class V30ResponseHeadersValidator(APICallResponseHeadersValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory + schema_validators_factory = oas30_read_schema_validators_factory - return response.data - def _unmarshal(self, param_or_media_type, value): - return super(ResponseValidator, self)._unmarshal( - param_or_media_type, value, context=UnmarshalContext.RESPONSE, - ) +class V30ResponseValidator(APICallResponseValidator): + spec_validator_cls = OpenAPIV30SpecValidator + schema_casters_factory = oas30_read_schema_casters_factory + schema_validators_factory = oas30_read_schema_validators_factory + + +class V31ResponseDataValidator(APICallResponseDataValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31ResponseHeadersValidator(APICallResponseHeadersValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31ResponseValidator(APICallResponseValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookResponseDataValidator(WebhookResponseDataValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookResponseHeadersValidator(WebhookResponseHeadersValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory + + +class V31WebhookResponseValidator(WebhookResponseValidator): + spec_validator_cls = OpenAPIV31SpecValidator + schema_casters_factory = oas31_schema_casters_factory + schema_validators_factory = oas31_schema_validators_factory diff --git a/openapi_core/validation/schemas/__init__.py b/openapi_core/validation/schemas/__init__.py new file mode 100644 index 00000000..4fdf466b --- /dev/null +++ b/openapi_core/validation/schemas/__init__.py @@ -0,0 +1,26 @@ +from openapi_schema_validator import OAS30ReadValidator +from openapi_schema_validator import OAS30WriteValidator +from openapi_schema_validator import OAS31Validator + +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + +__all__ = [ + "oas30_write_schema_validators_factory", + "oas30_read_schema_validators_factory", + "oas31_schema_validators_factory", +] + +oas30_write_schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator, +) + +oas30_read_schema_validators_factory = SchemaValidatorsFactory( + OAS30ReadValidator, +) + +oas31_schema_validators_factory = SchemaValidatorsFactory( + OAS31Validator, + # FIXME: OpenAPI 3.1 schema validator uses OpenAPI 3.0 format checker. + # See https://github.com/python-openapi/openapi-core/issues/506 + format_checker=OAS30ReadValidator.FORMAT_CHECKER, +) diff --git a/openapi_core/validation/schemas/datatypes.py b/openapi_core/validation/schemas/datatypes.py new file mode 100644 index 00000000..9cec4b7d --- /dev/null +++ b/openapi_core/validation/schemas/datatypes.py @@ -0,0 +1,7 @@ +from typing import Any +from typing import Callable +from typing import Dict + +FormatValidator = Callable[[Any], bool] + +FormatValidatorsDict = Dict[str, FormatValidator] diff --git a/openapi_core/validation/schemas/exceptions.py b/openapi_core/validation/schemas/exceptions.py new file mode 100644 index 00000000..437a273c --- /dev/null +++ b/openapi_core/validation/schemas/exceptions.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from dataclasses import field +from typing import Iterable + +from openapi_core.exceptions import OpenAPIError + + +class ValidateError(OpenAPIError): + """Schema validate operation error""" + + +@dataclass +class InvalidSchemaValue(ValidateError): + """Value not valid for schema""" + + value: str + type: str + schema_errors: Iterable[Exception] = field(default_factory=list) + + def __str__(self) -> str: + return ( + "Value {value} not valid for schema of type {type}: {errors}" + ).format(value=self.value, type=self.type, errors=self.schema_errors) diff --git a/openapi_core/validation/schemas/factories.py b/openapi_core/validation/schemas/factories.py new file mode 100644 index 00000000..11be59a5 --- /dev/null +++ b/openapi_core/validation/schemas/factories.py @@ -0,0 +1,67 @@ +from copy import deepcopy +from typing import Optional +from typing import Type + +from jsonschema._format import FormatChecker +from jsonschema.protocols import Validator +from jsonschema_path import SchemaPath + +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.validators import SchemaValidator + + +class SchemaValidatorsFactory: + def __init__( + self, + schema_validator_class: Type[Validator], + format_checker: Optional[FormatChecker] = None, + ): + self.schema_validator_class = schema_validator_class + if format_checker is None: + format_checker = self.schema_validator_class.FORMAT_CHECKER + self.format_checker = format_checker + + def get_format_checker( + self, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + ) -> FormatChecker: + if format_validators is None: + format_checker = deepcopy(self.format_checker) + else: + format_checker = FormatChecker([]) + format_checker = self._add_validators( + format_checker, format_validators + ) + format_checker = self._add_validators( + format_checker, extra_format_validators + ) + return format_checker + + def _add_validators( + self, + base_format_checker: FormatChecker, + format_validators: Optional[FormatValidatorsDict] = None, + ) -> FormatChecker: + if format_validators is not None: + for name, check in format_validators.items(): + base_format_checker.checks(name)(check) + return base_format_checker + + def create( + self, + schema: SchemaPath, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + ) -> Validator: + format_checker = self.get_format_checker( + format_validators, extra_format_validators + ) + with schema.resolve() as resolved: + jsonschema_validator = self.schema_validator_class( + resolved.contents, + _resolver=resolved.resolver, + format_checker=format_checker, + ) + + return SchemaValidator(schema, jsonschema_validator) diff --git a/openapi_core/validation/schemas/validators.py b/openapi_core/validation/schemas/validators.py new file mode 100644 index 00000000..6ae1b2eb --- /dev/null +++ b/openapi_core/validation/schemas/validators.py @@ -0,0 +1,167 @@ +import logging +from functools import cached_property +from functools import partial +from typing import Any +from typing import Iterator +from typing import Optional + +from jsonschema.exceptions import FormatError +from jsonschema.protocols import Validator +from jsonschema_path import SchemaPath + +from openapi_core.validation.schemas.datatypes import FormatValidator +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue +from openapi_core.validation.schemas.exceptions import ValidateError + +log = logging.getLogger(__name__) + + +class SchemaValidator: + def __init__( + self, + schema: SchemaPath, + validator: Validator, + ): + self.schema = schema + self.validator = validator + + def __contains__(self, schema_format: str) -> bool: + return schema_format in self.validator.format_checker.checkers + + def validate(self, value: Any) -> None: + errors_iter = self.validator.iter_errors(value) + errors = tuple(errors_iter) + if errors: + schema_type = self.schema.getkey("type", "any") + raise InvalidSchemaValue(value, schema_type, schema_errors=errors) + + def evolve(self, schema: SchemaPath) -> "SchemaValidator": + cls = self.__class__ + + with schema.resolve() as resolved: + validator = self.validator.evolve( + schema=resolved.contents, _resolver=resolved.resolver + ) + return cls(schema, validator) + + def type_validator( + self, value: Any, type_override: Optional[str] = None + ) -> bool: + callable = self.get_type_validator_callable( + type_override=type_override + ) + return callable(value) + + def format_validator(self, value: Any) -> bool: + try: + self.format_validator_callable(value) + except FormatError: + return False + else: + return True + + def get_type_validator_callable( + self, type_override: Optional[str] = None + ) -> FormatValidator: + schema_type = type_override or self.schema.getkey("type") + if schema_type in self.validator.TYPE_CHECKER._type_checkers: + return partial( + self.validator.TYPE_CHECKER.is_type, type=schema_type + ) + + return lambda x: True + + @cached_property + def format_validator_callable(self) -> FormatValidator: + schema_format = self.schema.getkey("format") + if schema_format in self.validator.format_checker.checkers: + return partial( + self.validator.format_checker.check, format=schema_format + ) + + return lambda x: True + + def get_primitive_type(self, value: Any) -> Optional[str]: + schema_types = self.schema.getkey("type") + if isinstance(schema_types, str): + return schema_types + if schema_types is None: + schema_types = sorted(self.validator.TYPE_CHECKER._type_checkers) + assert isinstance(schema_types, list) + for schema_type in schema_types: + result = self.type_validator(value, type_override=schema_type) + if not result: + continue + result = self.format_validator(value) + if not result: + continue + assert isinstance(schema_type, (str, type(None))) + return schema_type + # OpenAPI 3.0: None is not a primitive type so None value will not find any type + return None + + def iter_valid_schemas(self, value: Any) -> Iterator[SchemaPath]: + yield self.schema + + one_of_schema = self.get_one_of_schema(value) + if one_of_schema is not None: + yield one_of_schema + + yield from self.iter_any_of_schemas(value) + yield from self.iter_all_of_schemas(value) + + def get_one_of_schema( + self, + value: Any, + ) -> Optional[SchemaPath]: + if "oneOf" not in self.schema: + return None + + one_of_schemas = self.schema / "oneOf" + for subschema in one_of_schemas: + validator = self.evolve(subschema) + try: + validator.validate(value) + except ValidateError: + continue + else: + return subschema + + log.warning("valid oneOf schema not found") + return None + + def iter_any_of_schemas( + self, + value: Any, + ) -> Iterator[SchemaPath]: + if "anyOf" not in self.schema: + return + + any_of_schemas = self.schema / "anyOf" + for subschema in any_of_schemas: + validator = self.evolve(subschema) + try: + validator.validate(value) + except ValidateError: + continue + else: + yield subschema + + def iter_all_of_schemas( + self, + value: Any, + ) -> Iterator[SchemaPath]: + if "allOf" not in self.schema: + return + + all_of_schemas = self.schema / "allOf" + for subschema in all_of_schemas: + if "type" not in subschema: + continue + validator = self.evolve(subschema) + try: + validator.validate(value) + except ValidateError: + log.warning("invalid allOf schema found") + else: + yield subschema diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 4d3639ca..a627f8a0 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -1,56 +1,292 @@ """OpenAPI core validation validators module""" -from openapi_core.unmarshalling.schemas.util import build_format_checker +import warnings +from functools import cached_property +from typing import Any +from typing import Mapping +from typing import Optional +from typing import Tuple +from urllib.parse import urljoin -class BaseValidator(object): +from jsonschema_path import SchemaPath +from openapi_spec_validator.validation.types import SpecValidatorType + +from openapi_core.casting.schemas.factories import SchemaCastersFactory +from openapi_core.deserializing.media_types import ( + media_type_deserializers_factory, +) +from openapi_core.deserializing.media_types.datatypes import ( + MediaTypeDeserializersDict, +) +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.deserializing.styles.exceptions import ( + EmptyQueryParameterValue, +) +from openapi_core.deserializing.styles.factories import ( + StyleDeserializersFactory, +) +from openapi_core.protocols import Request +from openapi_core.protocols import WebhookRequest +from openapi_core.schema.parameters import get_style_and_explode +from openapi_core.templating.media_types.datatypes import MediaType +from openapi_core.templating.paths.datatypes import PathOperationServer +from openapi_core.templating.paths.finders import APICallPathFinder +from openapi_core.templating.paths.finders import BasePathFinder +from openapi_core.templating.paths.finders import WebhookPathFinder +from openapi_core.templating.paths.types import PathFinderType +from openapi_core.validation.schemas.datatypes import FormatValidatorsDict +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +class BaseValidator: + schema_casters_factory: SchemaCastersFactory = NotImplemented + schema_validators_factory: SchemaValidatorsFactory = NotImplemented + path_finder_cls: PathFinderType = NotImplemented + spec_validator_cls: Optional[SpecValidatorType] = None def __init__( - self, spec, - base_url=None, - custom_formatters=None, custom_media_type_deserializers=None, + self, + spec: SchemaPath, + base_url: Optional[str] = None, + style_deserializers_factory: StyleDeserializersFactory = style_deserializers_factory, + media_type_deserializers_factory: MediaTypeDeserializersFactory = media_type_deserializers_factory, + schema_casters_factory: Optional[SchemaCastersFactory] = None, + schema_validators_factory: Optional[SchemaValidatorsFactory] = None, + path_finder_cls: Optional[PathFinderType] = None, + spec_validator_cls: Optional[SpecValidatorType] = None, + format_validators: Optional[FormatValidatorsDict] = None, + extra_format_validators: Optional[FormatValidatorsDict] = None, + extra_media_type_deserializers: Optional[ + MediaTypeDeserializersDict + ] = None, ): self.spec = spec self.base_url = base_url - self.custom_formatters = custom_formatters or {} - self.custom_media_type_deserializers = custom_media_type_deserializers - self.format_checker = build_format_checker(**self.custom_formatters) + self.schema_casters_factory = ( + schema_casters_factory or self.schema_casters_factory + ) + if self.schema_casters_factory is NotImplemented: + raise NotImplementedError("schema_casters_factory is not assigned") + self.style_deserializers_factory = style_deserializers_factory + self.media_type_deserializers_factory = ( + media_type_deserializers_factory + ) + self.schema_validators_factory = ( + schema_validators_factory or self.schema_validators_factory + ) + if self.schema_validators_factory is NotImplemented: + raise NotImplementedError( + "schema_validators_factory is not assigned" + ) + self.path_finder_cls = path_finder_cls or self.path_finder_cls + if self.path_finder_cls is NotImplemented: + raise NotImplementedError("path_finder_cls is not assigned") + self.spec_validator_cls = spec_validator_cls or self.spec_validator_cls + self.format_validators = format_validators + self.extra_format_validators = extra_format_validators + self.extra_media_type_deserializers = extra_media_type_deserializers + + @cached_property + def path_finder(self) -> BasePathFinder: + return self.path_finder_cls(self.spec, base_url=self.base_url) - def _find_path(self, request): - from openapi_core.templating.paths.finders import PathFinder - finder = PathFinder(self.spec, base_url=self.base_url) - return finder.find(request) + def check_spec(self, spec: SchemaPath) -> None: + if self.spec_validator_cls is None: + return - def _deserialise_media_type(self, media_type, value): - from openapi_core.deserializing.media_types.factories import ( - MediaTypeDeserializersFactory, + validator = self.spec_validator_cls(spec) + validator.validate() + + def _find_media_type( + self, content: SchemaPath, mimetype: Optional[str] = None + ) -> MediaType: + from openapi_core.templating.media_types.finders import MediaTypeFinder + + finder = MediaTypeFinder(content) + if mimetype is None: + return finder.get_first() + return finder.find(mimetype) + + def _deserialise_media_type( + self, + media_type: SchemaPath, + mimetype: str, + parameters: Mapping[str, str], + value: bytes, + ) -> Any: + schema = media_type.get("schema") + encoding = None + if "encoding" in media_type: + encoding = media_type.get("encoding") + deserializer = self.media_type_deserializers_factory.create( + mimetype, + schema=schema, + parameters=parameters, + encoding=encoding, + extra_media_type_deserializers=self.extra_media_type_deserializers, ) - deserializers_factory = MediaTypeDeserializersFactory( - self.custom_media_type_deserializers) - deserializer = deserializers_factory.create(media_type) - return deserializer(value) + return deserializer.deserialize(value) - def _cast(self, param_or_media_type, value): - # return param_or_media_type.cast(value) - if not param_or_media_type.schema: - return value + def _deserialise_style( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Any: + name = name or param_or_header["name"] + style, explode = get_style_and_explode(param_or_header) + schema = param_or_header / "schema" + deserializer = self.style_deserializers_factory.create( + style, explode, schema, name=name + ) + return deserializer.deserialize(location) + + def _cast(self, schema: SchemaPath, value: Any) -> Any: + caster = self.schema_casters_factory.create(schema) + return caster.cast(value) + + def _validate_schema(self, schema: SchemaPath, value: Any) -> None: + validator = self.schema_validators_factory.create( + schema, + format_validators=self.format_validators, + extra_format_validators=self.extra_format_validators, + ) + validator.validate(value) - from openapi_core.casting.schemas.factories import SchemaCastersFactory - casters_factory = SchemaCastersFactory() - caster = casters_factory.create(param_or_media_type.schema) - return caster(value) + def _get_param_or_header_and_schema( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Tuple[Any, Optional[SchemaPath]]: + schema: Optional[SchemaPath] = None + # Simple scenario + if "content" not in param_or_header: + casted, schema = self._get_simple_param_or_header( + param_or_header, location, name=name + ) + # Complex scenario + else: + casted, schema = self._get_complex_param_or_header( + param_or_header, location, name=name + ) - def _unmarshal(self, param_or_media_type, value, context): - if not param_or_media_type.schema: - return value + if schema is None: + return casted, None + self._validate_schema(schema, casted) + return casted, schema - from openapi_core.unmarshalling.schemas.factories import ( - SchemaUnmarshallersFactory, + def _get_simple_param_or_header( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Tuple[Any, SchemaPath]: + allow_empty_values = param_or_header.getkey("allowEmptyValue") + if allow_empty_values: + warnings.warn( + "Use of allowEmptyValue property is deprecated", + DeprecationWarning, + ) + # in simple scenrios schema always exist + schema = param_or_header / "schema" + try: + deserialised = self._deserialise_style( + param_or_header, location, name=name + ) + except KeyError: + if "default" not in schema: + raise + return schema["default"], schema + if allow_empty_values is not None: + warnings.warn( + "Use of allowEmptyValue property is deprecated", + DeprecationWarning, + ) + if allow_empty_values is None or not allow_empty_values: + # if "in" not defined then it's a Header + location_name = param_or_header.getkey("in", "header") + if ( + location_name == "query" + and deserialised == "" + and not allow_empty_values + ): + param_or_header_name = param_or_header["name"] + raise EmptyQueryParameterValue(param_or_header_name) + casted = self._cast(schema, deserialised) + return casted, schema + + def _get_complex_param_or_header( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Tuple[Any, Optional[SchemaPath]]: + content = param_or_header / "content" + raw = self._get_media_type_value(param_or_header, location, name=name) + return self._get_content_schema_value_and_schema(raw, content) + + def _get_content_schema_value_and_schema( + self, + raw: bytes, + content: SchemaPath, + mimetype: Optional[str] = None, + ) -> Tuple[Any, Optional[SchemaPath]]: + mime_type, parameters, media_type = self._find_media_type( + content, mimetype ) - unmarshallers_factory = SchemaUnmarshallersFactory( - self.spec._resolver, self.format_checker, - self.custom_formatters, context=context, + # no point to catch KetError + # in complex scenrios schema doesn't exist + deserialised = self._deserialise_media_type( + media_type, mime_type, parameters, raw ) - unmarshaller = unmarshallers_factory.create( - param_or_media_type.schema) - return unmarshaller(value) + + if "schema" not in media_type: + return deserialised, None + + schema = media_type / "schema" + # cast for urlencoded content + # FIXME: don't cast data from media type deserializer + # See https://github.com/python-openapi/openapi-core/issues/706 + casted = self._cast(schema, deserialised) + return casted, schema + + def _get_content_and_schema( + self, raw: bytes, content: SchemaPath, mimetype: Optional[str] = None + ) -> Tuple[Any, Optional[SchemaPath]]: + casted, schema = self._get_content_schema_value_and_schema( + raw, content, mimetype + ) + if schema is None: + return casted, None + self._validate_schema(schema, casted) + return casted, schema + + def _get_media_type_value( + self, + param_or_header: SchemaPath, + location: Mapping[str, Any], + name: Optional[str] = None, + ) -> Any: + name = name or param_or_header["name"] + return location[name] + + +class BaseAPICallValidator(BaseValidator): + path_finder_cls = APICallPathFinder + + def _find_path(self, request: Request) -> PathOperationServer: + path_pattern = getattr(request, "path_pattern", None) or request.path + full_url = urljoin(request.host_url, path_pattern) + return self.path_finder.find(request.method, full_url) + + +class BaseWebhookValidator(BaseValidator): + path_finder_cls = WebhookPathFinder + + def _find_path(self, request: WebhookRequest) -> PathOperationServer: + return self.path_finder.find(request.method, request.name) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..1f694bf8 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2898 @@ +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.3.5" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "aiohappyeyeballs-2.3.5-py3-none-any.whl", hash = "sha256:4d6dea59215537dbc746e93e779caea8178c866856a721c9c660d7a5a7b8be03"}, + {file = "aiohappyeyeballs-2.3.5.tar.gz", hash = "sha256:6fa48b9f1317254f122a07a131a86b71ca6946ca989ce6326fff54a99a920105"}, +] + +[[package]] +name = "aiohttp" +version = "3.11.18" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "aiohttp-3.11.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4"}, + {file = "aiohttp-3.11.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6"}, + {file = "aiohttp-3.11.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609"}, + {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55"}, + {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f"}, + {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94"}, + {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1"}, + {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643"}, + {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868"}, + {file = "aiohttp-3.11.18-cp310-cp310-win32.whl", hash = "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f"}, + {file = "aiohttp-3.11.18-cp310-cp310-win_amd64.whl", hash = "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9"}, + {file = "aiohttp-3.11.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9"}, + {file = "aiohttp-3.11.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b"}, + {file = "aiohttp-3.11.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66"}, + {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756"}, + {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717"}, + {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4"}, + {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f"}, + {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f"}, + {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd"}, + {file = "aiohttp-3.11.18-cp311-cp311-win32.whl", hash = "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d"}, + {file = "aiohttp-3.11.18-cp311-cp311-win_amd64.whl", hash = "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6"}, + {file = "aiohttp-3.11.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2"}, + {file = "aiohttp-3.11.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508"}, + {file = "aiohttp-3.11.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e"}, + {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f"}, + {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f"}, + {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec"}, + {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6"}, + {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1"}, + {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea"}, + {file = "aiohttp-3.11.18-cp312-cp312-win32.whl", hash = "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8"}, + {file = "aiohttp-3.11.18-cp312-cp312-win_amd64.whl", hash = "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8"}, + {file = "aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811"}, + {file = "aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804"}, + {file = "aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd"}, + {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c"}, + {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118"}, + {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1"}, + {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000"}, + {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261"}, + {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7"}, + {file = "aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78"}, + {file = "aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01"}, + {file = "aiohttp-3.11.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:469ac32375d9a716da49817cd26f1916ec787fc82b151c1c832f58420e6d3533"}, + {file = "aiohttp-3.11.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3cec21dd68924179258ae14af9f5418c1ebdbba60b98c667815891293902e5e0"}, + {file = "aiohttp-3.11.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b426495fb9140e75719b3ae70a5e8dd3a79def0ae3c6c27e012fc59f16544a4a"}, + {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2f41203e2808616292db5d7170cccf0c9f9c982d02544443c7eb0296e8b0c7"}, + {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc0ae0a5e9939e423e065a3e5b00b24b8379f1db46046d7ab71753dfc7dd0e1"}, + {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe7cdd3f7d1df43200e1c80f1aed86bb36033bf65e3c7cf46a2b97a253ef8798"}, + {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5199be2a2f01ffdfa8c3a6f5981205242986b9e63eb8ae03fd18f736e4840721"}, + {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ccec9e72660b10f8e283e91aa0295975c7bd85c204011d9f5eb69310555cf30"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1596ebf17e42e293cbacc7a24c3e0dc0f8f755b40aff0402cb74c1ff6baec1d3"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eab7b040a8a873020113ba814b7db7fa935235e4cbaf8f3da17671baa1024863"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d61df4a05476ff891cff0030329fee4088d40e4dc9b013fac01bc3c745542c2"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:46533e6792e1410f9801d09fd40cbbff3f3518d1b501d6c3c5b218f427f6ff08"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c1b90407ced992331dd6d4f1355819ea1c274cc1ee4d5b7046c6761f9ec11829"}, + {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a2fd04ae4971b914e54fe459dd7edbbd3f2ba875d69e057d5e3c8e8cac094935"}, + {file = "aiohttp-3.11.18-cp39-cp39-win32.whl", hash = "sha256:b2f317d1678002eee6fe85670039fb34a757972284614638f82b903a03feacdc"}, + {file = "aiohttp-3.11.18-cp39-cp39-win_amd64.whl", hash = "sha256:5e7007b8d1d09bce37b54111f593d173691c530b80f27c6493b928dabed9e6ef"}, + {file = "aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.3.0" +aiosignal = ">=1.1.2" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aioitertools" +version = "0.12.0" +description = "itertools and builtins for AsyncIO and mixed iterables" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"starlette\"" +files = [ + {file = "aioitertools-0.12.0-py3-none-any.whl", hash = "sha256:fc1f5fac3d737354de8831cbba3eb04f79dd649d8f3afb4c5b114925e662a796"}, + {file = "aioitertools-0.12.0.tar.gz", hash = "sha256:c2a9055b4fbb7705f561b9d86053e8af5d10cc845d22c32008c43490b2d8dd6b"}, +] + +[package.dependencies] +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.10\""} + +[package.extras] +dev = ["attribution (==1.8.0)", "black (==24.8.0)", "build (>=1.2)", "coverage (==7.6.1)", "flake8 (==7.1.1)", "flit (==3.9.0)", "mypy (==1.11.2)", "ufmt (==2.7.1)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.0.2)", "sphinx-mdinclude (==0.6.2)"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "anyio" +version = "3.7.1" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, +] + +[package.dependencies] +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] +test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4) ; python_version < \"3.8\"", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; python_version < \"3.12\" and platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (<0.22)"] + +[[package]] +name = "asgiref" +version = "3.7.2" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "async-timeout" +version = "4.0.3" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, + {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, +] + +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.1.1) ; platform_python_implementation == \"CPython\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-xdist[psutil]"] + +[[package]] +name = "babel" +version = "2.13.1" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"}, + {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"}, +] + +[package.dependencies] +setuptools = {version = "*", markers = "python_version >= \"3.12\""} + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "backrefs" +version = "5.7.post1" +description = "A wrapper around re and regex that adds additional back references." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "backrefs-5.7.post1-py310-none-any.whl", hash = "sha256:c5e3fd8fd185607a7cb1fefe878cfb09c34c0be3c18328f12c574245f1c0287e"}, + {file = "backrefs-5.7.post1-py311-none-any.whl", hash = "sha256:712ea7e494c5bf3291156e28954dd96d04dc44681d0e5c030adf2623d5606d51"}, + {file = "backrefs-5.7.post1-py312-none-any.whl", hash = "sha256:a6142201c8293e75bce7577ac29e1a9438c12e730d73a59efdd1b75528d1a6c5"}, + {file = "backrefs-5.7.post1-py38-none-any.whl", hash = "sha256:ec61b1ee0a4bfa24267f6b67d0f8c5ffdc8e0d7dc2f18a2685fd1d8d9187054a"}, + {file = "backrefs-5.7.post1-py39-none-any.whl", hash = "sha256:05c04af2bf752bb9a6c9dcebb2aff2fab372d3d9d311f2a138540e307756bd3a"}, + {file = "backrefs-5.7.post1.tar.gz", hash = "sha256:8b0f83b770332ee2f1c8244f4e03c77d127a0fa529328e6a0e77fa25bee99678"}, +] + +[package.extras] +extras = ["regex"] + +[[package]] +name = "black" +version = "24.8.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, + {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, + {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, + {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, + {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, + {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, + {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, + {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, + {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, + {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, + {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, + {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, + {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, + {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, + {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, + {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, + {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, + {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, + {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, + {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, + {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, + {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "blinker" +version = "1.7.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, + {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, +] + +[[package]] +name = "bump2version" +version = "1.0.1" +description = "Version-bump your software with a single command!" +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410"}, + {file = "bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"}, +] + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main", "dev", "docs"] +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +groups = ["main", "dev", "docs"] +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.3.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, + {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, + {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, + {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, + {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, + {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, + {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, + {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, + {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, + {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, + {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, + {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, + {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, + {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "deptry" +version = "0.20.0" +description = "A command line utility to check for unused, missing and transitive dependencies in a Python project." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "deptry-0.20.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:41434d95124851b83cb05524d1a09ad6fea62006beafed2ef90a6b501c1b237f"}, + {file = "deptry-0.20.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:b3b4b22d1406147de5d606a24042126cd74d52fdfdb0232b9c5fd0270d601610"}, + {file = "deptry-0.20.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:012fb106dbea6ca95196cdcd75ac90c516c8f01292f7934f2e802a7cf025a660"}, + {file = "deptry-0.20.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ce3920e2bd6d2b4427ab31ab8efb94bbef897001c2d395782bc30002966d12d"}, + {file = "deptry-0.20.0-cp38-abi3-win_amd64.whl", hash = "sha256:0c90ce64e637d0e902bc97c5a020adecfee9e9f09ee0bf4c61554994139bebdb"}, + {file = "deptry-0.20.0-cp38-abi3-win_arm64.whl", hash = "sha256:6886ff44aaf26fd83093f14f844ebc84589d90df9bbad9a1625e8a080e6f1be2"}, + {file = "deptry-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ace3b39b1d0763f357c79bab003d1b135bea2eb61102be539992621a42d1ac7b"}, + {file = "deptry-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d1a00f8c9e6c0829a4a523edd5e526e3df06d2b50e0a99446f09f9723df2efad"}, + {file = "deptry-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e233859f150df70ffff76e95f9b7326fc25494b9beb26e776edae20f0f515e7d"}, + {file = "deptry-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f92e7e97ef42477717747b190bc6796ab94b35655af126d8c577f7eae0eb3a9"}, + {file = "deptry-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6cee6005997791bb77155667be055333fb63ae9a24f0f103f25faf1e7affe34"}, + {file = "deptry-0.20.0.tar.gz", hash = "sha256:62e9aaf3aea9e2ca66c85da98a0ba0290b4d3daea4e1d0ad937d447bd3c36402"}, +] + +[package.dependencies] +click = ">=8.0.0,<9" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "distlib" +version = "0.3.7" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, + {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, +] + +[[package]] +name = "django" +version = "4.2.22" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "django-4.2.22-py3-none-any.whl", hash = "sha256:0a32773b5b7f4e774a155ee253ab24a841fed7e9e9061db08bf2ce9711da404d"}, + {file = "django-4.2.22.tar.gz", hash = "sha256:e726764b094407c313adba5e2e866ab88f00436cad85c540a5bf76dc0a912c9e"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "djangorestframework" +version = "3.15.2" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20"}, + {file = "djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad"}, +] + +[package.dependencies] +django = ">=4.2" + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.0.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "execnet-2.0.2-py3-none-any.whl", hash = "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41"}, + {file = "execnet-2.0.2.tar.gz", hash = "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "falcon" +version = "4.0.2" +description = "The ultra-reliable, fast ASGI+WSGI framework for building data plane APIs at scale." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "falcon-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8eab0212e77017385d48be2dfe9f5b32305fc9e4066cd298e4bb39e666e114c8"}, + {file = "falcon-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:942129dd3bfb56342ac368f05ff4f9be53e98883b4227089fce2fd616ebc6ef3"}, + {file = "falcon-4.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60e7b6e5ee44bb2411a7f47bb64e0b225f11cca6ddf91e5130d456242095f0d7"}, + {file = "falcon-4.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:330f1623e579575a9e3d90c2a15aebe100b2afa1e18a4bee2ddaa9a570e97902"}, + {file = "falcon-4.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d7cfac5cfca69373d1f65211d75767ed4f2d53b46554307427ec00a6f7f87c1"}, + {file = "falcon-4.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:da3d942dd34f7a5213987bd053c3b52b6eb75fcfd342dc4fea9241f79a6529b3"}, + {file = "falcon-4.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5169e064bbe5dece52e088e3e8b17cae429f1e04c7aef8c31ae350303b19c620"}, + {file = "falcon-4.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:0d62e565b9e71b52b59e03130b2b71345a6873f5299aad6a141caf4a58661b41"}, + {file = "falcon-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb6ee1aee9ff6a656762cf5fcd2e6c5dced410ca990016be2bc193e6b74ae9da"}, + {file = "falcon-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f1a16d8bdc8ef9cf2832a6ca6d43b156b613fb1587cd08cc928c7b8a118ea0a"}, + {file = "falcon-4.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aee81fc4702eef5bccb640b93187fdf36ca2606fca511982069dbc60be2d1c93"}, + {file = "falcon-4.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c1dbcec63d9118c3dfac1f810305128c4fffe26f4f99a7b4e379dec95fc3bfc"}, + {file = "falcon-4.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2892ab1232d3a7cc9890b1b539c471fe04c54f826704f9d05efe5632f18efa1"}, + {file = "falcon-4.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:af68482b408bde53a77b36e45317767dfc5b6fce1525f5b25d65f57f35d33fca"}, + {file = "falcon-4.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:53d84de83abe1a2094b319a4f018ab6c5773d9c2c841b528662aa151ab9df35c"}, + {file = "falcon-4.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:1d06bbbccdb58522b2a6bb2e79074844b0db0da1fff407725858a02515e15bbd"}, + {file = "falcon-4.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:23b0419a9a025745734022aaa2e65447595e539ba27352b3f59d86b288f614db"}, + {file = "falcon-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:524d7b75f7368fe82e94ed16370db5a27bb4b2d066470cba53f02304264447e8"}, + {file = "falcon-4.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c6b1d7451d5dee4be9b67a75e2a4a0b024dccffedd4e7c7a09513733b5a11db"}, + {file = "falcon-4.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59bb4a29626c5e610c62620a1395755e8c7b5509385b80d3637fbc8a604d29a3"}, + {file = "falcon-4.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26c9ed2912ee48e2e1e7eca3e7e85ab664ff07bd321097a26e4ad6168059424"}, + {file = "falcon-4.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a12bbf3482b7ef1db0c6727c2ad8be5c3ac777d892e56a170e0b4b93651c915"}, + {file = "falcon-4.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a601de7816138f17bf168262e0bceb128fdd1ea2f29ddae035585b5da9223a21"}, + {file = "falcon-4.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:eec3feda4a9cd773203401e3cf425728a13bf5055b22243b1452e9ad963634f5"}, + {file = "falcon-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:110b172afe337fbae802f1402c89a5dfe6392f3b8ce4f2ecdfd5cee48f68b805"}, + {file = "falcon-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b3a5db14cf2ef05f8f9630468c03939b86dc16115a5250a1870dac3dca1e04ba"}, + {file = "falcon-4.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b4d41ce29c2b5c5b18021320e9e0977ba47ade46b67face52ee1325e2ea4"}, + {file = "falcon-4.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56af3b8838da2e19ae56b4e1bac168669ba257d6941f94933dc4f814fe721c08"}, + {file = "falcon-4.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec939d26dd77f57f08f3e13fb14b4e609c0baf073dc3f0c368f0e4cc10439528"}, + {file = "falcon-4.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9bfd751dd898505e17152d7ecfcdc457c9d85bceed7e651a9915183bd4afc86b"}, + {file = "falcon-4.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b85f9c6f50a7465303290cb305404ea5c1ddeff6702179c1a8879c4693b0e5e"}, + {file = "falcon-4.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:a410e4023999a74ccf615fafa646b112044b987ef5901c8e5c5b79b163f2b3ba"}, + {file = "falcon-4.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90ba6475a6dc591e72f23f3751476711f9a820a6eca05cb9435c9d039f7c534c"}, + {file = "falcon-4.0.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:90c8614f8fc7bf144338cbd9f9ac2ccf824c139e57f8122d3e873e92e4a4b053"}, + {file = "falcon-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9709fd9181f58d492463b951cc42fb33b230e8f261128bc8252a37a4553f318"}, + {file = "falcon-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:427c20ceb367039b856506d7baeef17c7f0c40b8fcbf1147c0e76f33a574a7cf"}, + {file = "falcon-4.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fb50cebc3cae6720ccf4a05fccb233ea6a88e803828a07c063d6dce10a74e0e"}, + {file = "falcon-4.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:628c450e14af811f13db6334265d7ff8a7b8a25ece1bde35d09a367a72046533"}, + {file = "falcon-4.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e04b30a7f89e5413e00c5cd1ea62bf7948323eb0220f8a5bbf705abae266a384"}, + {file = "falcon-4.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9095a36b8eeb80207322393b3bc88edaacd0426c2907e8427617618421bde9cc"}, + {file = "falcon-4.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0adc6c2887f9d7ed55fe38edef055cc85c26762e392d80dca8765184c180b921"}, + {file = "falcon-4.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:7bffb4cadcbf7c5994695d421ef5305ad8315cfbefe971713046967614f0ffa4"}, + {file = "falcon-4.0.2-py3-none-any.whl", hash = "sha256:077b2abf001940c6128c9b5872ae8147fe13f6ca333f928d8045d7601a5e847e"}, + {file = "falcon-4.0.2.tar.gz", hash = "sha256:58f4b9c9da4c9b1e2c9f396ad7ef897701b3c7c7c87227f0bd1aee40c7fbc525"}, +] + +[package.extras] +test = ["pytest"] + +[[package]] +name = "fastapi" +version = "0.115.12" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d"}, + {file = "fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] + +[[package]] +name = "flake8" +version = "2.3.0" +description = "the modular source code checker: pep8, pyflakes and co" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "flake8-2.3.0-py2.py3-none-any.whl", hash = "sha256:c99cc9716d6655d9c8bcb1e77632b8615bf0abd282d7abd9f5c2148cad7fc669"}, + {file = "flake8-2.3.0.tar.gz", hash = "sha256:5ee1a43ccd0716d6061521eec6937c983efa027793013e572712c4da55c7c83e"}, +] + +[package.dependencies] +mccabe = ">=0.2.1" +pep8 = ">=1.5.7" +pyflakes = ">=0.8.1" + +[[package]] +name = "flask" +version = "3.0.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "frozenlist" +version = "1.6.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "frozenlist-1.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e"}, + {file = "frozenlist-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352"}, + {file = "frozenlist-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd"}, + {file = "frozenlist-1.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02"}, + {file = "frozenlist-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3"}, + {file = "frozenlist-1.6.0-cp310-cp310-win32.whl", hash = "sha256:0e6f8653acb82e15e5443dba415fb62a8732b68fe09936bb6d388c725b57f812"}, + {file = "frozenlist-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f1a39819a5a3e84304cd286e3dc62a549fe60985415851b3337b6f5cc91907f1"}, + {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d"}, + {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0"}, + {file = "frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff"}, + {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1"}, + {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e"}, + {file = "frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860"}, + {file = "frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603"}, + {file = "frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1"}, + {file = "frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29"}, + {file = "frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590"}, + {file = "frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046"}, + {file = "frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770"}, + {file = "frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc"}, + {file = "frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878"}, + {file = "frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e"}, + {file = "frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117"}, + {file = "frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f"}, + {file = "frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188"}, + {file = "frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e"}, + {file = "frozenlist-1.6.0-cp313-cp313-win32.whl", hash = "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4"}, + {file = "frozenlist-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd"}, + {file = "frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64"}, + {file = "frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91"}, + {file = "frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e"}, + {file = "frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911"}, + {file = "frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497"}, + {file = "frozenlist-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f"}, + {file = "frozenlist-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348"}, + {file = "frozenlist-1.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:536a1236065c29980c15c7229fbb830dedf809708c10e159b8136534233545f0"}, + {file = "frozenlist-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ed5e3a4462ff25ca84fb09e0fada8ea267df98a450340ead4c91b44857267d70"}, + {file = "frozenlist-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e19c0fc9f4f030fcae43b4cdec9e8ab83ffe30ec10c79a4a43a04d1af6c5e1ad"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7c608f833897501dac548585312d73a7dca028bf3b8688f0d712b7acfaf7fb3"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0dbae96c225d584f834b8d3cc688825911960f003a85cb0fd20b6e5512468c42"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625170a91dd7261a1d1c2a0c1a353c9e55d21cd67d0852185a5fef86587e6f5f"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1db8b2fc7ee8a940b547a14c10e56560ad3ea6499dc6875c354e2335812f739d"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4da6fc43048b648275a220e3a61c33b7fff65d11bdd6dcb9d9c145ff708b804c"}, + {file = "frozenlist-1.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef8e7e8f2f3820c5f175d70fdd199b79e417acf6c72c5d0aa8f63c9f721646f"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa733d123cc78245e9bb15f29b44ed9e5780dc6867cfc4e544717b91f980af3b"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ba7f8d97152b61f22d7f59491a781ba9b177dd9f318486c5fbc52cde2db12189"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:56a0b8dd6d0d3d971c91f1df75e824986667ccce91e20dca2023683814344791"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5c9e89bf19ca148efcc9e3c44fd4c09d5af85c8a7dd3dbd0da1cb83425ef4983"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1330f0a4376587face7637dfd245380a57fe21ae8f9d360c1c2ef8746c4195fa"}, + {file = "frozenlist-1.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2187248203b59625566cac53572ec8c2647a140ee2738b4e36772930377a533c"}, + {file = "frozenlist-1.6.0-cp39-cp39-win32.whl", hash = "sha256:2b8cf4cfea847d6c12af06091561a89740f1f67f331c3fa8623391905e878530"}, + {file = "frozenlist-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:1255d5d64328c5a0d066ecb0f02034d086537925f1f04b50b1ae60d37afbf572"}, + {file = "frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191"}, + {file = "frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68"}, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "griffe" +version = "1.3.0" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "griffe-1.3.0-py3-none-any.whl", hash = "sha256:3c85b5704136379bed767ef9c1d7776cac50886e341b61b71c6983dfe04d7cb2"}, + {file = "griffe-1.3.0.tar.gz", hash = "sha256:878cd99709b833fab7c41a6545188bcdbc1fcb3b441374449d34b69cb864de69"}, +] + +[package.dependencies] +colorama = ">=0.4" + +[[package]] +name = "griffe-typingdoc" +version = "0.2.7" +description = "Griffe extension for PEP 727 – Documentation Metadata in Typing." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "griffe_typingdoc-0.2.7-py3-none-any.whl", hash = "sha256:74a825df32fc87fcae2f221df5c5524dca23155cd3c04ec9fa46493669d3cf54"}, + {file = "griffe_typingdoc-0.2.7.tar.gz", hash = "sha256:800841e99f8844ea3c1fae80b19bede7d8eed4195a2586f5db753f7a73f4931d"}, +] + +[package.dependencies] +griffe = ">=0.49" +typing-extensions = ">=4.7" + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "identify" +version = "2.5.31" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "identify-2.5.31-py2.py3-none-any.whl", hash = "sha256:90199cb9e7bd3c5407a9b7e81b4abec4bb9d249991c79439ec8af740afc6293d"}, + {file = "identify-2.5.31.tar.gz", hash = "sha256:7736b3c7a28233637e3c36550646fc6389bedd74ae84cb788200cc8e2dd60b75"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +groups = ["main", "dev", "docs"] +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +markers = "python_version < \"3.10\"" +files = [ + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isodate" +version = "0.7.2" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +groups = ["dev"] +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["main"] +files = [ + {file = "jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8"}, + {file = "jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001"}, +] + +[package.dependencies] +pathable = ">=0.4.1,<0.5.0" +PyYAML = ">=5.1" +referencing = "<0.37.0" +requests = ">=2.31.0,<3.0.0" + +[[package]] +name = "jsonschema-specifications" +version = "2023.7.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2023.7.1-py3-none-any.whl", hash = "sha256:05adf340b659828a004220a9613be00fa3f223f2b82002e273dee62fd50524b1"}, + {file = "jsonschema_specifications-2023.7.1.tar.gz", hash = "sha256:c91a50404e88a1f6ba40636778e2ee08f6e24c5613fe4c53ac24578a5a7f72bb"}, +] + +[package.dependencies] +referencing = ">=0.28.0" + +[[package]] +name = "lazy-object-proxy" +version = "1.9.0" +description = "A fast and thorough lazy object proxy." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "lazy-object-proxy-1.9.0.tar.gz", hash = "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-win32.whl", hash = "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455"}, + {file = "lazy_object_proxy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-win32.whl", hash = "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586"}, + {file = "lazy_object_proxy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win32.whl", hash = "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734"}, + {file = "lazy_object_proxy-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-win32.whl", hash = "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82"}, + {file = "lazy_object_proxy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-win32.whl", hash = "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821"}, + {file = "lazy_object_proxy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f"}, +] + +[[package]] +name = "legacy-cgi" +version = "2.6.2" +description = "Fork of the standard library cgi and cgitb modules, being deprecated in PEP-594" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version >= \"3.13\"" +files = [ + {file = "legacy_cgi-2.6.2-py3-none-any.whl", hash = "sha256:a7b83afb1baf6ebeb56522537c5943ef9813cf933f6715e88a803f7edbce0bff"}, + {file = "legacy_cgi-2.6.2.tar.gz", hash = "sha256:9952471ceb304043b104c22d00b4f333cac27a6abe446d8a528fc437cf13c85f"}, +] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev", "docs"] +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +groups = ["docs"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "1.2.0" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"}, + {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-material" +version = "9.6.14" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b"}, + {file = "mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754"}, +] + +[package.dependencies] +babel = ">=2.10,<3.0" +backrefs = ">=5.7.post1,<6.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.1,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.6,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mkdocstrings" +version = "0.26.1" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocstrings-0.26.1-py3-none-any.whl", hash = "sha256:29738bfb72b4608e8e55cc50fb8a54f325dc7ebd2014e4e3881a49892d5983cf"}, + {file = "mkdocstrings-0.26.1.tar.gz", hash = "sha256:bb8b8854d6713d5348ad05b069a09f3b79edbc6a0f33a34c6821141adb03fe33"}, +] + +[package.dependencies] +click = ">=7.0" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +Jinja2 = ">=2.11.1" +Markdown = ">=3.6" +MarkupSafe = ">=1.1" +mkdocs = ">=1.4" +mkdocs-autorefs = ">=1.2" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +platformdirs = ">=2.2" +pymdown-extensions = ">=6.3" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "1.11.1" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocstrings_python-1.11.1-py3-none-any.whl", hash = "sha256:a21a1c05acef129a618517bb5aae3e33114f569b11588b1e7af3e9d4061a71af"}, + {file = "mkdocstrings_python-1.11.1.tar.gz", hash = "sha256:8824b115c5359304ab0b5378a91f6202324a849e1da907a3485b59208b797322"}, +] + +[package.dependencies] +griffe = ">=0.49" +mkdocs-autorefs = ">=1.2" +mkdocstrings = ">=0.26" + +[[package]] +name = "more-itertools" +version = "10.5.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6"}, + {file = "more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef"}, +] + +[[package]] +name = "multidict" +version = "6.1.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, + {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, + {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, + {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, + {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, + {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, + {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, + {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, + {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, + {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, + {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, + {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, + {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, + {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, + {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, + {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, + {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, + {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, + {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, + {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, + {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, + {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, + {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, + {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, + {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, + {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, + {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, + {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, + {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, + {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, + {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, + {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, + {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "mypy" +version = "1.14.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +groups = ["dev"] +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["main"] +files = [ + {file = "openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3"}, + {file = "openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee"}, +] + +[package.dependencies] +jsonschema = ">=4.19.1,<5.0.0" +jsonschema-specifications = ">=2023.5.2" +rfc3339-validator = "*" + +[[package]] +name = "openapi-spec-validator" +version = "0.7.1" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" +optional = false +python-versions = ">=3.8.0,<4.0.0" +groups = ["main"] +files = [ + {file = "openapi_spec_validator-0.7.1-py3-none-any.whl", hash = "sha256:3c81825043f24ccbcd2f4b149b11e8231abce5ba84f37065e14ec947d8f4e959"}, + {file = "openapi_spec_validator-0.7.1.tar.gz", hash = "sha256:8577b85a8268685da6f8aa30990b83b7960d4d1117e901d451b5d572605e5ec7"}, +] + +[package.dependencies] +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" +lazy-object-proxy = ">=1.7.1,<2.0.0" +openapi-schema-validator = ">=0.6.0,<0.7.0" + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +groups = ["dev", "docs"] +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "paginate" +version = "0.5.7" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, + {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, +] + +[package.extras] +dev = ["pytest", "tox"] +lint = ["black"] + +[[package]] +name = "parse" +version = "1.20.2" +description = "parse() is the opposite of format()" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558"}, + {file = "parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce"}, +] + +[[package]] +name = "pathable" +version = "0.4.3" +description = "Object-oriented paths" +optional = false +python-versions = ">=3.7.0,<4.0.0" +groups = ["main"] +files = [ + {file = "pathable-0.4.3-py3-none-any.whl", hash = "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14"}, + {file = "pathable-0.4.3.tar.gz", hash = "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +groups = ["dev", "docs"] +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "pep8" +version = "1.7.1" +description = "Python style guide checker" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pep8-1.7.1-py2.py3-none-any.whl", hash = "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee"}, + {file = "pep8-1.7.1.tar.gz", hash = "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374"}, +] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +groups = ["dev", "docs"] +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.5.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, + {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "propcache" +version = "0.2.0" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b"}, + {file = "propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850"}, + {file = "propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b"}, + {file = "propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336"}, + {file = "propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad"}, + {file = "propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de"}, + {file = "propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4"}, + {file = "propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b"}, + {file = "propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b"}, + {file = "propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1"}, + {file = "propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7"}, + {file = "propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e"}, + {file = "propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23"}, + {file = "propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348"}, + {file = "propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5"}, + {file = "propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763"}, + {file = "propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf"}, + {file = "propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83"}, + {file = "propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544"}, + {file = "propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032"}, + {file = "propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6"}, + {file = "propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9"}, + {file = "propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7"}, + {file = "propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed"}, + {file = "propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d"}, + {file = "propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638"}, + {file = "propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12"}, + {file = "propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d"}, + {file = "propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798"}, + {file = "propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9"}, + {file = "propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df"}, + {file = "propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036"}, + {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"}, +] + +[[package]] +name = "pydantic" +version = "1.10.22" +description = "Data validation and settings management using python type hints" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "pydantic-1.10.22-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:57889565ccc1e5b7b73343329bbe6198ebc472e3ee874af2fa1865cfe7048228"}, + {file = "pydantic-1.10.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:90729e22426de79bc6a3526b4c45ec4400caf0d4f10d7181ba7f12c01bb3897d"}, + {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8684d347f351554ec94fdcb507983d3116dc4577fb8799fed63c65869a2d10"}, + {file = "pydantic-1.10.22-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8dad498ceff2d9ef1d2e2bc6608f5b59b8e1ba2031759b22dfb8c16608e1802"}, + {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fac529cc654d4575cf8de191cce354b12ba705f528a0a5c654de6d01f76cd818"}, + {file = "pydantic-1.10.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4148232aded8dd1dd13cf910a01b32a763c34bd79a0ab4d1ee66164fcb0b7b9d"}, + {file = "pydantic-1.10.22-cp310-cp310-win_amd64.whl", hash = "sha256:ece68105d9e436db45d8650dc375c760cc85a6793ae019c08769052902dca7db"}, + {file = "pydantic-1.10.22-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e530a8da353f791ad89e701c35787418605d35085f4bdda51b416946070e938"}, + {file = "pydantic-1.10.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:654322b85642e9439d7de4c83cb4084ddd513df7ff8706005dada43b34544946"}, + {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8bece75bd1b9fc1c32b57a32831517943b1159ba18b4ba32c0d431d76a120ae"}, + {file = "pydantic-1.10.22-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eccb58767f13c6963dcf96d02cb8723ebb98b16692030803ac075d2439c07b0f"}, + {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7778e6200ff8ed5f7052c1516617423d22517ad36cc7a3aedd51428168e3e5e8"}, + {file = "pydantic-1.10.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffe02767d27c39af9ca7dc7cd479c00dda6346bb62ffc89e306f665108317a2"}, + {file = "pydantic-1.10.22-cp311-cp311-win_amd64.whl", hash = "sha256:23bc19c55427091b8e589bc08f635ab90005f2dc99518f1233386f46462c550a"}, + {file = "pydantic-1.10.22-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:92d0f97828a075a71d9efc65cf75db5f149b4d79a38c89648a63d2932894d8c9"}, + {file = "pydantic-1.10.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af5a2811b6b95b58b829aeac5996d465a5f0c7ed84bd871d603cf8646edf6ff"}, + {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cf06d8d40993e79af0ab2102ef5da77b9ddba51248e4cb27f9f3f591fbb096e"}, + {file = "pydantic-1.10.22-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:184b7865b171a6057ad97f4a17fbac81cec29bd103e996e7add3d16b0d95f609"}, + {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:923ad861677ab09d89be35d36111156063a7ebb44322cdb7b49266e1adaba4bb"}, + {file = "pydantic-1.10.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:82d9a3da1686443fb854c8d2ab9a473251f8f4cdd11b125522efb4d7c646e7bc"}, + {file = "pydantic-1.10.22-cp312-cp312-win_amd64.whl", hash = "sha256:1612604929af4c602694a7f3338b18039d402eb5ddfbf0db44f1ebfaf07f93e7"}, + {file = "pydantic-1.10.22-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b259dc89c9abcd24bf42f31951fb46c62e904ccf4316393f317abeeecda39978"}, + {file = "pydantic-1.10.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9238aa0964d80c0908d2f385e981add58faead4412ca80ef0fa352094c24e46d"}, + {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8029f05b04080e3f1a550575a1bca747c0ea4be48e2d551473d47fd768fc1b"}, + {file = "pydantic-1.10.22-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c06918894f119e0431a36c9393bc7cceeb34d1feeb66670ef9b9ca48c073937"}, + {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e205311649622ee8fc1ec9089bd2076823797f5cd2c1e3182dc0e12aab835b35"}, + {file = "pydantic-1.10.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:815f0a73d5688d6dd0796a7edb9eca7071bfef961a7b33f91e618822ae7345b7"}, + {file = "pydantic-1.10.22-cp313-cp313-win_amd64.whl", hash = "sha256:9dfce71d42a5cde10e78a469e3d986f656afc245ab1b97c7106036f088dd91f8"}, + {file = "pydantic-1.10.22-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3ecaf8177b06aac5d1f442db1288e3b46d9f05f34fd17fdca3ad34105328b61a"}, + {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb36c2de9ea74bd7f66b5481dea8032d399affd1cbfbb9bb7ce539437f1fce62"}, + {file = "pydantic-1.10.22-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6b8d14a256be3b8fff9286d76c532f1a7573fbba5f189305b22471c6679854d"}, + {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:1c33269e815db4324e71577174c29c7aa30d1bba51340ce6be976f6f3053a4c6"}, + {file = "pydantic-1.10.22-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:8661b3ab2735b2a9ccca2634738534a795f4a10bae3ab28ec0a10c96baa20182"}, + {file = "pydantic-1.10.22-cp37-cp37m-win_amd64.whl", hash = "sha256:22bdd5fe70d4549995981c55b970f59de5c502d5656b2abdfcd0a25be6f3763e"}, + {file = "pydantic-1.10.22-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e3f33d1358aa4bc2795208cc29ff3118aeaad0ea36f0946788cf7cadeccc166b"}, + {file = "pydantic-1.10.22-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:813f079f9cd136cac621f3f9128a4406eb8abd2ad9fdf916a0731d91c6590017"}, + {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab618ab8dca6eac7f0755db25f6aba3c22c40e3463f85a1c08dc93092d917704"}, + {file = "pydantic-1.10.22-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d128e1aaa38db88caca920d5822c98fc06516a09a58b6d3d60fa5ea9099b32cc"}, + {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:cc97bbc25def7025e55fc9016080773167cda2aad7294e06a37dda04c7d69ece"}, + {file = "pydantic-1.10.22-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dda5d7157d543b1fa565038cae6e952549d0f90071c839b3740fb77c820fab8"}, + {file = "pydantic-1.10.22-cp38-cp38-win_amd64.whl", hash = "sha256:a093fe44fe518cb445d23119511a71f756f8503139d02fcdd1173f7b76c95ffe"}, + {file = "pydantic-1.10.22-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec54c89b2568b258bb30d7348ac4d82bec1b58b377fb56a00441e2ac66b24587"}, + {file = "pydantic-1.10.22-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8f1d1a1532e4f3bcab4e34e8d2197a7def4b67072acd26cfa60e92d75803a48"}, + {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ad83ca35508c27eae1005b6b61f369f78aae6d27ead2135ec156a2599910121"}, + {file = "pydantic-1.10.22-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53cdb44b78c420f570ff16b071ea8cd5a477635c6b0efc343c8a91e3029bbf1a"}, + {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:16d0a5ae9d98264186ce31acdd7686ec05fd331fab9d68ed777d5cb2d1514e5e"}, + {file = "pydantic-1.10.22-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8aee040e25843f036192b1a1af62117504a209a043aa8db12e190bb86ad7e611"}, + {file = "pydantic-1.10.22-cp39-cp39-win_amd64.whl", hash = "sha256:7f691eec68dbbfca497d3c11b92a3e5987393174cbedf03ec7a4184c35c2def6"}, + {file = "pydantic-1.10.22-py3-none-any.whl", hash = "sha256:343037d608bcbd34df937ac259708bfc83664dadf88afe8516c4f282d7d471a9"}, + {file = "pydantic-1.10.22.tar.gz", hash = "sha256:ee1006cebd43a8e7158fb7190bb8f4e2da9649719bff65d0c287282ec38dec6d"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata ; python_version < \"3.8\""] + +[[package]] +name = "pymdown-extensions" +version = "10.9" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626"}, + {file = "pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-aiohttp" +version = "1.0.5" +description = "Pytest plugin for aiohttp support" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-aiohttp-1.0.5.tar.gz", hash = "sha256:880262bc5951e934463b15e3af8bb298f11f7d4d3ebac970aab425aff10a780a"}, + {file = "pytest_aiohttp-1.0.5-py3-none-any.whl", hash = "sha256:63a5360fd2f34dda4ab8e6baee4c5f5be4cd186a403cabd498fced82ac9c561e"}, +] + +[package.dependencies] +aiohttp = ">=3.8.1" +pytest = ">=6.1.0" +pytest-asyncio = ">=0.17.2" + +[package.extras] +testing = ["coverage (==6.2)", "mypy (==0.931)"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cache" +version = "1.0" +description = "pytest plugin with mechanisms for caching across test runs" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pytest-cache-1.0.tar.gz", hash = "sha256:be7468edd4d3d83f1e844959fd6e3fd28e77a481440a7118d430130ea31b07a9"}, +] + +[package.dependencies] +execnet = ">=1.1.dev1" +pytest = ">=2.2" + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-flake8" +version = "0.1" +description = "pytest plugin to check FLAKE8 requirements" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pytest-flake8-0.1.tar.gz", hash = "sha256:6b30619538937f274a373ace5fe2895def15066f0d3bad5784458ae0bce61a60"}, + {file = "pytest_flake8-0.1-py2.py3-none-any.whl", hash = "sha256:d2ecd5343ae56b4ac27ffa09d88111cc97dd7fdbc881231dfcdbc852f9ea5121"}, +] + +[package.dependencies] +flake8 = ">=2.3" +pytest = ">=2.4.2" +pytest-cache = "*" + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["docs"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-multipart" +version = "0.0.20" +description = "A streaming multipart parser for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, + {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +groups = ["main", "dev", "docs"] +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +groups = ["docs"] +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "referencing" +version = "0.30.2" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "referencing-0.30.2-py3-none-any.whl", hash = "sha256:449b6669b6121a9e96a7f9e410b245d471e8d48964c67113ce9afe50c8dd7bdf"}, + {file = "referencing-0.30.2.tar.gz", hash = "sha256:794ad8003c65938edcdbc027f1933215e0d0ccc0291e3ce20a4d87432b59efc0"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "responses" +version = "0.25.7" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c"}, + {file = "responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rpds-py" +version = "0.24.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rpds_py-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:006f4342fe729a368c6df36578d7a348c7c716be1da0a1a0f86e3021f8e98724"}, + {file = "rpds_py-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2d53747da70a4e4b17f559569d5f9506420966083a31c5fbd84e764461c4444b"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8acd55bd5b071156bae57b555f5d33697998752673b9de554dd82f5b5352727"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e80d375134ddb04231a53800503752093dbb65dad8dabacce2c84cccc78e964"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60748789e028d2a46fc1c70750454f83c6bdd0d05db50f5ae83e2db500b34da5"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e1daf5bf6c2be39654beae83ee6b9a12347cb5aced9a29eecf12a2d25fff664"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b221c2457d92a1fb3c97bee9095c874144d196f47c038462ae6e4a14436f7bc"}, + {file = "rpds_py-0.24.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66420986c9afff67ef0c5d1e4cdc2d0e5262f53ad11e4f90e5e22448df485bf0"}, + {file = "rpds_py-0.24.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:43dba99f00f1d37b2a0265a259592d05fcc8e7c19d140fe51c6e6f16faabeb1f"}, + {file = "rpds_py-0.24.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a88c0d17d039333a41d9bf4616bd062f0bd7aa0edeb6cafe00a2fc2a804e944f"}, + {file = "rpds_py-0.24.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc31e13ce212e14a539d430428cd365e74f8b2d534f8bc22dd4c9c55b277b875"}, + {file = "rpds_py-0.24.0-cp310-cp310-win32.whl", hash = "sha256:fc2c1e1b00f88317d9de6b2c2b39b012ebbfe35fe5e7bef980fd2a91f6100a07"}, + {file = "rpds_py-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0145295ca415668420ad142ee42189f78d27af806fcf1f32a18e51d47dd2052"}, + {file = "rpds_py-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2d3ee4615df36ab8eb16c2507b11e764dcc11fd350bbf4da16d09cda11fcedef"}, + {file = "rpds_py-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e13ae74a8a3a0c2f22f450f773e35f893484fcfacb00bb4344a7e0f4f48e1f97"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf86f72d705fc2ef776bb7dd9e5fbba79d7e1f3e258bf9377f8204ad0fc1c51e"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c43583ea8517ed2e780a345dd9960896afc1327e8cf3ac8239c167530397440d"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cd031e63bc5f05bdcda120646a0d32f6d729486d0067f09d79c8db5368f4586"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34d90ad8c045df9a4259c47d2e16a3f21fdb396665c94520dbfe8766e62187a4"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e838bf2bb0b91ee67bf2b889a1a841e5ecac06dd7a2b1ef4e6151e2ce155c7ae"}, + {file = "rpds_py-0.24.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04ecf5c1ff4d589987b4d9882872f80ba13da7d42427234fce8f22efb43133bc"}, + {file = "rpds_py-0.24.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:630d3d8ea77eabd6cbcd2ea712e1c5cecb5b558d39547ac988351195db433f6c"}, + {file = "rpds_py-0.24.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ebcb786b9ff30b994d5969213a8430cbb984cdd7ea9fd6df06663194bd3c450c"}, + {file = "rpds_py-0.24.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:174e46569968ddbbeb8a806d9922f17cd2b524aa753b468f35b97ff9c19cb718"}, + {file = "rpds_py-0.24.0-cp311-cp311-win32.whl", hash = "sha256:5ef877fa3bbfb40b388a5ae1cb00636a624690dcb9a29a65267054c9ea86d88a"}, + {file = "rpds_py-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:e274f62cbd274359eff63e5c7e7274c913e8e09620f6a57aae66744b3df046d6"}, + {file = "rpds_py-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205"}, + {file = "rpds_py-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029"}, + {file = "rpds_py-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9"}, + {file = "rpds_py-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7"}, + {file = "rpds_py-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91"}, + {file = "rpds_py-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56"}, + {file = "rpds_py-0.24.0-cp312-cp312-win32.whl", hash = "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30"}, + {file = "rpds_py-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034"}, + {file = "rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c"}, + {file = "rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d"}, + {file = "rpds_py-0.24.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7"}, + {file = "rpds_py-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad"}, + {file = "rpds_py-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120"}, + {file = "rpds_py-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9"}, + {file = "rpds_py-0.24.0-cp313-cp313-win32.whl", hash = "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143"}, + {file = "rpds_py-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a"}, + {file = "rpds_py-0.24.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114"}, + {file = "rpds_py-0.24.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d"}, + {file = "rpds_py-0.24.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7"}, + {file = "rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d"}, + {file = "rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797"}, + {file = "rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c"}, + {file = "rpds_py-0.24.0-cp313-cp313t-win32.whl", hash = "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba"}, + {file = "rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350"}, + {file = "rpds_py-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a36b452abbf29f68527cf52e181fced56685731c86b52e852053e38d8b60bc8d"}, + {file = "rpds_py-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b3b397eefecec8e8e39fa65c630ef70a24b09141a6f9fc17b3c3a50bed6b50e"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdabcd3beb2a6dca7027007473d8ef1c3b053347c76f685f5f060a00327b8b65"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5db385bacd0c43f24be92b60c857cf760b7f10d8234f4bd4be67b5b20a7c0b6b"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8097b3422d020ff1c44effc40ae58e67d93e60d540a65649d2cdaf9466030791"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:493fe54318bed7d124ce272fc36adbf59d46729659b2c792e87c3b95649cdee9"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8aa362811ccdc1f8dadcc916c6d47e554169ab79559319ae9fae7d7752d0d60c"}, + {file = "rpds_py-0.24.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8f9a6e7fd5434817526815f09ea27f2746c4a51ee11bb3439065f5fc754db58"}, + {file = "rpds_py-0.24.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8205ee14463248d3349131bb8099efe15cd3ce83b8ef3ace63c7e976998e7124"}, + {file = "rpds_py-0.24.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:921ae54f9ecba3b6325df425cf72c074cd469dea843fb5743a26ca7fb2ccb149"}, + {file = "rpds_py-0.24.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:32bab0a56eac685828e00cc2f5d1200c548f8bc11f2e44abf311d6b548ce2e45"}, + {file = "rpds_py-0.24.0-cp39-cp39-win32.whl", hash = "sha256:f5c0ed12926dec1dfe7d645333ea59cf93f4d07750986a586f511c0bc61fe103"}, + {file = "rpds_py-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:afc6e35f344490faa8276b5f2f7cbf71f88bc2cda4328e00553bd451728c571f"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:619ca56a5468f933d940e1bf431c6f4e13bef8e688698b067ae68eb4f9b30e3a"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b28e5122829181de1898c2c97f81c0b3246d49f585f22743a1246420bb8d399"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e5ab32cf9eb3647450bc74eb201b27c185d3857276162c101c0f8c6374e098"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:208b3a70a98cf3710e97cabdc308a51cd4f28aa6e7bb11de3d56cd8b74bab98d"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbc4362e06f950c62cad3d4abf1191021b2ffaf0b31ac230fbf0526453eee75e"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebea2821cdb5f9fef44933617be76185b80150632736f3d76e54829ab4a3b4d1"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4df06c35465ef4d81799999bba810c68d29972bf1c31db61bfdb81dd9d5bb"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3aa13bdf38630da298f2e0d77aca967b200b8cc1473ea05248f6c5e9c9bdb44"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:041f00419e1da7a03c46042453598479f45be3d787eb837af382bfc169c0db33"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d8754d872a5dfc3c5bf9c0e059e8107451364a30d9fd50f1f1a85c4fb9481164"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:896c41007931217a343eff197c34513c154267636c8056fb409eafd494c3dcdc"}, + {file = "rpds_py-0.24.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92558d37d872e808944c3c96d0423b8604879a3d1c86fdad508d7ed91ea547d5"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f9e0057a509e096e47c87f753136c9b10d7a91842d8042c2ee6866899a717c0d"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6e109a454412ab82979c5b1b3aee0604eca4bbf9a02693bb9df027af2bfa91a"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc1c892b1ec1f8cbd5da8de287577b455e388d9c328ad592eabbdcb6fc93bee5"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c39438c55983d48f4bb3487734d040e22dad200dab22c41e331cee145e7a50d"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d7e8ce990ae17dda686f7e82fd41a055c668e13ddcf058e7fb5e9da20b57793"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ea7f4174d2e4194289cb0c4e172d83e79a6404297ff95f2875cf9ac9bced8ba"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb2954155bb8f63bb19d56d80e5e5320b61d71084617ed89efedb861a684baea"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04f2b712a2206e13800a8136b07aaedc23af3facab84918e7aa89e4be0260032"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:eda5c1e2a715a4cbbca2d6d304988460942551e4e5e3b7457b50943cd741626d"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:9abc80fe8c1f87218db116016de575a7998ab1629078c90840e8d11ab423ee25"}, + {file = "rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a727fd083009bc83eb83d6950f0c32b3c94c8b80a9b667c87f4bd1274ca30ba"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e0f3ef95795efcd3b2ec3fe0a5bcfb5dadf5e3996ea2117427e524d4fbf309c6"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:2c13777ecdbbba2077670285dd1fe50828c8742f6a4119dbef6f83ea13ad10fb"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e8d804c2ccd618417e96720ad5cd076a86fa3f8cb310ea386a3e6229bae7d1"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd822f019ccccd75c832deb7aa040bb02d70a92eb15a2f16c7987b7ad4ee8d83"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0047638c3aa0dbcd0ab99ed1e549bbf0e142c9ecc173b6492868432d8989a046"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5b66d1b201cc71bc3081bc2f1fc36b0c1f268b773e03bbc39066651b9e18391"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbcbb6db5582ea33ce46a5d20a5793134b5365110d84df4e30b9d37c6fd40ad3"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:63981feca3f110ed132fd217bf7768ee8ed738a55549883628ee3da75bb9cb78"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3a55fc10fdcbf1a4bd3c018eea422c52cf08700cf99c28b5cb10fe97ab77a0d3"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:c30ff468163a48535ee7e9bf21bd14c7a81147c0e58a36c1078289a8ca7af0bd"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:369d9c6d4c714e36d4a03957b4783217a3ccd1e222cdd67d464a3a479fc17796"}, + {file = "rpds_py-0.24.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:24795c099453e3721fda5d8ddd45f5dfcc8e5a547ce7b8e9da06fecc3832e26f"}, + {file = "rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e"}, +] + +[[package]] +name = "setuptools" +version = "78.1.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev", "docs"] +files = [ + {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, + {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, +] +markers = {docs = "python_version >= \"3.12\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main", "docs"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.0" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, + {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "starlette" +version = "0.44.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "starlette-0.44.0-py3-none-any.whl", hash = "sha256:19edeb75844c16dcd4f9dd72f22f9108c1539f3fc9c4c88885654fef64f85aea"}, + {file = "starlette-0.44.0.tar.gz", hash = "sha256:e35166950a3ccccc701962fe0711db0bc14f2ecd37c6f9fe5e3eae0cbaea8715"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "strict-rfc3339" +version = "0.7" +description = "Strict, simple, lightweight RFC3339 functions" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + +[[package]] +name = "tzdata" +version = "2023.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main", "dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, + {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.26.6" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "watchdog" +version = "4.0.2" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c50f148b31b03fbadd6d0b5980e38b558046b127dc483e5e4505fcef250f9503"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c7d4bf585ad501c5f6c980e7be9c4f15604c7cc150e942d82083b31a7548930"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:914285126ad0b6eb2258bbbcb7b288d9dfd655ae88fa28945be05a7b475a800b"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:984306dc4720da5498b16fc037b36ac443816125a3705dfde4fd90652d8028ef"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:aa160781cafff2719b663c8a506156e9289d111d80f3387cf3af49cedee1f040"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6ee8dedd255087bc7fe82adf046f0b75479b989185fb0bdf9a98b612170eac7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b4359067d30d5b864e09c8597b112fe0a0a59321a0f331498b013fb097406b4"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:770eef5372f146997638d737c9a3c597a3b41037cfbc5c41538fc27c09c3a3f9"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeea812f38536a0aa859972d50c76e37f4456474b02bd93674d1947cf1e39578"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2c45f6e1e57ebb4687690c05bc3a2c1fb6ab260550c4290b8abb1335e0fd08b"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10b6683df70d340ac3279eff0b2766813f00f35a1d37515d2c99959ada8f05fa"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7c739888c20f99824f7aa9d31ac8a97353e22d0c0e54703a547a218f6637eb3"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c100d09ac72a8a08ddbf0629ddfa0b8ee41740f9051429baa8e31bb903ad7508"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f5315a8c8dd6dd9425b974515081fc0aadca1d1d61e078d2246509fd756141ee"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2d468028a77b42cc685ed694a7a550a8d1771bb05193ba7b24006b8241a571a1"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f15edcae3830ff20e55d1f4e743e92970c847bcddc8b7509bcd172aa04de506e"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8"}, + {file = "watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19"}, + {file = "watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b"}, + {file = "watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c"}, + {file = "watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "webob" +version = "1.8.9" +description = "WSGI request and response object" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "WebOb-1.8.9-py2.py3-none-any.whl", hash = "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9"}, + {file = "webob-1.8.9.tar.gz", hash = "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589"}, +] + +[package.dependencies] +legacy-cgi = {version = ">=2.6", markers = "python_version >= \"3.13\""} + +[package.extras] +docs = ["Sphinx (>=1.7.5)", "pylons-sphinx-themes"] +testing = ["coverage", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "werkzeug" +version = "3.0.6" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "werkzeug-3.0.6-py3-none-any.whl", hash = "sha256:1bc0c2310d2fbb07b1dd1105eba2f7af72f322e1e455f2f93c993bee8c8a5f17"}, + {file = "werkzeug-3.0.6.tar.gz", hash = "sha256:a8dd59d4de28ca70471a34cba79bed5f7ef2e036a76b3ab0835474246eb41f8d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "yarl" +version = "1.18.3" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, + {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, + {file = "yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc"}, + {file = "yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b"}, + {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690"}, + {file = "yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6"}, + {file = "yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193"}, + {file = "yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae"}, + {file = "yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e"}, + {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a"}, + {file = "yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1"}, + {file = "yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"}, + {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"}, + {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"}, + {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"}, + {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"}, + {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"}, + {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"}, + {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"}, + {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"}, + {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"}, + {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719"}, + {file = "yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c"}, + {file = "yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910"}, + {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1"}, + {file = "yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5"}, + {file = "yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9"}, + {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"}, + {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.0" + +[[package]] +name = "zipp" +version = "3.19.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "docs"] +markers = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.19.1-py3-none-any.whl", hash = "sha256:2828e64edb5386ea6a52e7ba7cdb17bb30a73a858f5eb6eb93d8d36f5ea26091"}, + {file = "zipp-3.19.1.tar.gz", hash = "sha256:35427f6d5594f4acf82d25541438348c26736fa9b3afa2754bcd63cdb99d8e8f"}, +] + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[extras] +aiohttp = ["aiohttp", "multidict"] +django = ["django"] +falcon = ["falcon"] +fastapi = ["fastapi"] +flask = ["flask"] +requests = ["requests"] +starlette = ["aioitertools", "starlette"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9.0" +content-hash = "e65afc8fd5e0b210f7ad24da23b3321d7c9cd76cc8a81d7672d336668fbead0c" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..4621aaa7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,148 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.coverage.run] +branch = true +source =["openapi_core"] + +[tool.coverage.xml] +output = "reports/coverage.xml" + +[tool.mypy] +files = "openapi_core" +strict = true + +[[tool.mypy.overrides]] +module = [ + "asgiref.*", + "django.*", + "falcon.*", + "isodate.*", + "jsonschema.*", + "more_itertools.*", + "parse.*", + "requests.*", + "werkzeug.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "lazy_object_proxy.*" +ignore_missing_imports = true + +[tool.poetry] +name = "openapi-core" +version = "0.19.5" +description = "client-side and server-side support for the OpenAPI Specification v3" +authors = ["Artur Maciag "] +license = "BSD-3-Clause" +readme = "README.md" +repository = "https://github.com/python-openapi/openapi-core" +documentation = "https://openapi-core.readthedocs.io" +keywords = ["openapi", "swagger", "schema"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", +] +include = [ + {path = "tests", format = "sdist"}, +] + +[tool.poetry.dependencies] +python = "^3.9.0" +django = {version = ">=3.0", optional = true} +falcon = {version = ">=3.0", optional = true} +flask = {version = "*", optional = true} +aiohttp = {version = ">=3.0", optional = true} +starlette = {version = ">=0.26.1,<0.47.0", optional = true} +isodate = "*" +more-itertools = "*" +parse = "*" +openapi-schema-validator = "^0.6.0" +openapi-spec-validator = "^0.7.1" +requests = {version = "*", optional = true} +# werkzeug 3.1.2 changed the definition of Headers +# See https://github.com/python-openapi/openapi-core/issues/938 +werkzeug = "<3.1.2" +jsonschema-path = "^0.3.4" +jsonschema = "^4.23.0" +multidict = {version = "^6.0.4", optional = true} +aioitertools = {version = ">=0.11,<0.13", optional = true} +fastapi = {version = ">=0.111,<0.116", optional = true} +typing-extensions = "^4.8.0" + +[tool.poetry.extras] +django = ["django"] +falcon = ["falcon"] +fastapi = ["fastapi"] +flask = ["flask"] +requests = ["requests"] +aiohttp = ["aiohttp", "multidict"] +starlette = ["starlette", "aioitertools"] + +[tool.poetry.group.dev.dependencies] +black = ">=23.3,<25.0" +django = ">=3.0" +djangorestframework = "^3.11.2" +falcon = ">=3.0" +flask = "*" +isort = "^5.11.5" +pre-commit = "*" +pytest = "^8" +pytest-flake8 = "*" +pytest-cov = "*" +python-multipart = "*" +responses = "*" +starlette = ">=0.26.1,<0.47.0" +strict-rfc3339 = "^0.7" +webob = "*" +mypy = "^1.2" +httpx = ">=0.24,<0.29" +deptry = ">=0.11,<0.21" +aiohttp = "^3.8.4" +pytest-aiohttp = "^1.0.4" +bump2version = "^1.0.1" +pyflakes = "^3.1.0" +fastapi = ">=0.111,<0.116" + +[tool.poetry.group.docs.dependencies] +mkdocs = "^1.6.1" +mkdocstrings = {extras = ["python"], version = "^0.26.1"} +mkdocs-material = "^9.5.34" +griffe-typingdoc = "^0.2.7" + +[tool.pytest.ini_options] +addopts = """ +--capture=no +--verbose +--showlocals +--junitxml=reports/junit.xml +--cov=openapi_core +--cov-report=term-missing +--cov-report=xml +""" +asyncio_mode = "auto" +filterwarnings = [ + "error", + # falcon.media.handlers uses cgi to parse data + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", + "ignore:co_lnotab is deprecated, use co_lines instead:DeprecationWarning", +] + +[tool.black] +line-length = 79 + +[tool.isort] +profile = "black" +line_length = 79 +force_single_line = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 08cebe5e..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -isodate==0.6.0 -openapi-spec-validator -openapi-schema-validator -six -lazy-object-proxy -attrs -parse==1.14.0 -more-itertools>=5.0.0 diff --git a/requirements_2.7.txt b/requirements_2.7.txt deleted file mode 100644 index f4eb34c3..00000000 --- a/requirements_2.7.txt +++ /dev/null @@ -1,10 +0,0 @@ -isodate==0.6.0 -openapi-spec-validator -openapi-schema-validator -six -lazy-object-proxy -backports.functools-lru-cache -backports.functools-partialmethod -enum34 -attrs -more-itertools==5.0.0 diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index ddd5d34c..00000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,10 +0,0 @@ -mock==2.0.0 -pytest==3.5.0 -pytest-flake8 -pytest-cov==2.5.1 -falcon==2.0.0 -flask -django==2.2.13; python_version>="3.0" -requests==2.22.0 -responses==0.10.12 -webob diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9f7298df..00000000 --- a/setup.cfg +++ /dev/null @@ -1,60 +0,0 @@ -[metadata] -name = openapi-core -long_description = file: README.rst -long-description-content-type = text/x-rst; charset=UTF-8 -keywords = openapi, swagger, schema -classifiers = - Development Status :: 4 - Beta - Intended Audience :: Developers - Topic :: Software Development :: Libraries :: Python Modules - Operating System :: OS Independent - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Topic :: Software Development :: Libraries - -[options] -include_package_data = True -packages = find: -zip_safe = False -test_suite = tests -python_requires = >= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.* -setup_requires = - setuptools -install_requires = - isodate - openapi-spec-validator - openapi-schema-validator - six - lazy-object-proxy - attrs - werkzeug - parse - more-itertools - backports.functools-lru-cache; python_version<"3.0" - backports.functools-partialmethod; python_version<"3.0" -tests_require = - mock; python_version<"3.0" - pytest - pytest-flake8 - pytest-cov - falcon - flask - responses - webob - -[options.packages.find] -exclude = - tests - -[options.extras_require] -django = - django>=2.2; python_version>="3.0" -flask = flask -requests = requests - -[tool:pytest] -addopts = -sv --flake8 --junitxml reports/junit.xml --cov openapi_core --cov-report term-missing --cov-report xml:reports/coverage.xml diff --git a/setup.py b/setup.py deleted file mode 100644 index 144f0e5f..00000000 --- a/setup.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python -"""OpenAPI core setup module""" -import os -import re -import sys -try: - from setuptools import setup -except ImportError: - from ez_setup import use_setuptools - use_setuptools() - from setuptools import setup -finally: - from setuptools.command.test import test as TestCommand - - -def read_file(filename): - """Open and a file, read it and return its contents.""" - path = os.path.join(os.path.dirname(__file__), filename) - with open(path) as f: - return f.read() - - -def get_metadata(init_file): - """Read metadata from a given file and return a dictionary of them""" - return dict(re.findall("__([a-z]+)__ = '([^']+)'", init_file)) - - -class PyTest(TestCommand): - """Command to run unit tests after in-place build.""" - - def finalize_options(self): - TestCommand.finalize_options(self) - self.pytest_args = [] - - def run_tests(self): - # Importing here, `cause outside the eggs aren't loaded. - import pytest - errno = pytest.main(self.pytest_args) - sys.exit(errno) - - -init_path = os.path.join('openapi_core', '__init__.py') -init_py = read_file(init_path) -metadata = get_metadata(init_py) - - -if __name__ == '__main__': - setup( - version=metadata['version'], - author=metadata['author'], - author_email=metadata['email'], - url=metadata['url'], - cmdclass={'test': PyTest}, - setup_cfg=True, - ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 06d640c0..cea4a154 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,20 +1,53 @@ +from base64 import b64decode from os import path +from urllib import request -from openapi_spec_validator.schemas import read_yaml_file import pytest -from six.moves.urllib import request +from jsonschema_path import SchemaPath +from openapi_spec_validator.readers import read_from_filename from yaml import safe_load +from openapi_core import Spec -def spec_from_file(spec_file): + +def content_from_file(spec_file): directory = path.abspath(path.dirname(__file__)) path_full = path.join(directory, spec_file) - return read_yaml_file(path_full) + return read_from_filename(path_full) + + +def schema_path_from_file(spec_file): + spec_dict, base_uri = content_from_file(spec_file) + return SchemaPath.from_dict(spec_dict, base_uri=base_uri) + + +def schema_path_from_url(base_uri): + content = request.urlopen(base_uri) + spec_dict = safe_load(content) + return SchemaPath.from_dict(spec_dict, base_uri=base_uri) -def spec_from_url(spec_url): - content = request.urlopen(spec_url) - return safe_load(content) +def spec_from_file(spec_file): + schema_path = schema_path_from_file(spec_file) + return Spec(schema_path) + + +def spec_from_url(base_uri): + schema_path = schema_path_from_url(base_uri) + return Spec(schema_path) + + +@pytest.fixture(scope="session") +def data_gif(): + return b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" + ) class Factory(dict): @@ -22,9 +55,36 @@ class Factory(dict): __setattr__ = dict.__setitem__ -@pytest.fixture(scope='session') -def factory(): +@pytest.fixture(scope="session") +def content_factory(): return Factory( - spec_from_file=spec_from_file, - spec_from_url=spec_from_url, + from_file=content_from_file, ) + + +@pytest.fixture(scope="session") +def schema_path_factory(): + return Factory( + from_file=schema_path_from_file, + from_url=schema_path_from_url, + ) + + +@pytest.fixture(scope="session") +def spec_factory(schema_path_factory): + return Factory( + from_file=spec_from_file, + from_url=spec_from_url, + ) + + +@pytest.fixture(scope="session") +def v30_petstore_content(content_factory): + content, _ = content_factory.from_file("data/v3.0/petstore.yaml") + return content + + +@pytest.fixture(scope="session") +def v30_petstore_spec(v30_petstore_content): + base_uri = "file://tests/integration/data/v3.0/petstore.yaml" + return SchemaPath.from_dict(v30_petstore_content, base_uri=base_uri) diff --git a/tests/integration/contrib/aiohttp/conftest.py b/tests/integration/contrib/aiohttp/conftest.py new file mode 100644 index 00000000..ead341a5 --- /dev/null +++ b/tests/integration/contrib/aiohttp/conftest.py @@ -0,0 +1,119 @@ +import asyncio +import pathlib +from typing import Any +from unittest import mock + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient + +from openapi_core import V30RequestUnmarshaller +from openapi_core import V30ResponseUnmarshaller +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebResponse + + +@pytest.fixture +def schema_path(schema_path_factory): + directory = pathlib.Path(__file__).parent + specfile = directory / "data" / "v3.0" / "aiohttp_factory.yaml" + return schema_path_factory.from_file(str(specfile)) + + +@pytest.fixture +def response_getter() -> mock.MagicMock: + # Using a mock here allows us to control the return value for different scenarios. + return mock.MagicMock(return_value={"data": "data"}) + + +@pytest.fixture +def no_validation(response_getter): + async def test_route(request: web.Request) -> web.Response: + await asyncio.sleep(0) + response = web.json_response( + response_getter(), + headers={"X-Rate-Limit": "12"}, + status=200, + ) + return response + + return test_route + + +@pytest.fixture +def request_validation(schema_path, response_getter): + async def test_route(request: web.Request) -> web.Response: + request_body = await request.text() + openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body) + unmarshaller = V30RequestUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request) + response: dict[str, Any] = response_getter() + status = 200 + if result.errors: + status = 400 + response = {"errors": [{"message": str(e) for e in result.errors}]} + return web.json_response( + response, + headers={"X-Rate-Limit": "12"}, + status=status, + ) + + return test_route + + +@pytest.fixture +def response_validation(schema_path, response_getter): + async def test_route(request: web.Request) -> web.Response: + request_body = await request.text() + openapi_request = AIOHTTPOpenAPIWebRequest(request, body=request_body) + response_body = response_getter() + response = web.json_response( + response_body, + headers={"X-Rate-Limit": "12"}, + status=200, + ) + openapi_response = AIOHTTPOpenAPIWebResponse(response) + unmarshaller = V30ResponseUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request, openapi_response) + if result.errors: + response = web.json_response( + {"errors": [{"message": str(e) for e in result.errors}]}, + headers={"X-Rate-Limit": "12"}, + status=400, + ) + return response + + return test_route + + +@pytest.fixture( + params=["no_validation", "request_validation", "response_validation"] +) +def router( + request, + no_validation, + request_validation, + response_validation, +) -> web.RouteTableDef: + test_routes = dict( + no_validation=no_validation, + request_validation=request_validation, + response_validation=response_validation, + ) + router_ = web.RouteTableDef() + handler = test_routes[request.param] + router_.post("/browse/{id}/")(handler) + return router_ + + +@pytest.fixture +def app(router): + app = web.Application() + app.add_routes(router) + + return app + + +@pytest.fixture +async def client(app, aiohttp_client) -> TestClient: + return await aiohttp_client(app) diff --git a/tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml b/tests/integration/contrib/aiohttp/data/v3.0/aiohttp_factory.yaml similarity index 63% rename from tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml rename to tests/integration/contrib/aiohttp/data/v3.0/aiohttp_factory.yaml index d6b5e4be..4de7fac0 100644 --- a/tests/integration/contrib/falcon/data/v3.0/falcon_factory.yaml +++ b/tests/integration/contrib/aiohttp/data/v3.0/aiohttp_factory.yaml @@ -1,11 +1,12 @@ openapi: "3.0.0" info: - title: Basic OpenAPI specification used with test_falcon.TestFalconOpenAPIIValidation + title: Basic OpenAPI specification used with starlette integration tests version: "0.1" servers: - url: 'http://localhost' + description: 'testing' paths: - '/browse/{id}': + '/browse/{id}/': parameters: - name: id in: path @@ -13,13 +14,25 @@ paths: description: the ID of the resource to retrieve schema: type: integer - - name: detail_level + - name: q in: query - required: false - description: optional level of detail to provide + required: true + description: query key schema: - type: integer - get: + type: string + post: + requestBody: + description: request data + required: True + content: + application/json: + schema: + type: object + required: + - param1 + properties: + param1: + type: integer responses: 200: description: Return the resource. @@ -32,6 +45,12 @@ paths: properties: data: type: string + headers: + X-Rate-Limit: + description: Rate limit + schema: + type: integer + required: true default: description: Return errors. content: diff --git a/openapi_core/schema/external_docs/__init__.py b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/__init__.py similarity index 100% rename from openapi_core/schema/external_docs/__init__.py rename to tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/__init__.py diff --git a/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/__main__.py b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/__main__.py new file mode 100644 index 00000000..13109d64 --- /dev/null +++ b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/__main__.py @@ -0,0 +1,15 @@ +from aiohttp import web +from aiohttpproject.pets.views import PetPhotoView + +routes = [ + web.view("/v1/pets/{petId}/photo", PetPhotoView), +] + + +def get_app(loop=None): + app = web.Application(loop=loop) + app.add_routes(routes) + return app + + +app = get_app() diff --git a/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/openapi.py b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/openapi.py new file mode 100644 index 00000000..4ca6d9fa --- /dev/null +++ b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/openapi.py @@ -0,0 +1,9 @@ +from pathlib import Path + +import yaml + +from openapi_core import OpenAPI + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +openapi = OpenAPI.from_dict(spec_dict) diff --git a/openapi_core/schema/infos/__init__.py b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/pets/__init__.py similarity index 100% rename from openapi_core/schema/infos/__init__.py rename to tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/pets/__init__.py diff --git a/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/pets/views.py b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/pets/views.py new file mode 100644 index 00000000..ad721df3 --- /dev/null +++ b/tests/integration/contrib/aiohttp/data/v3.0/aiohttpproject/pets/views.py @@ -0,0 +1,52 @@ +from base64 import b64decode + +from aiohttp import web +from aiohttpproject.openapi import openapi + +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebRequest +from openapi_core.contrib.aiohttp import AIOHTTPOpenAPIWebResponse + + +class PetPhotoView(web.View): + OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" + ) + + async def get(self): + request_body = await self.request.text() + openapi_request = AIOHTTPOpenAPIWebRequest( + self.request, body=request_body + ) + request_unmarshalled = openapi.unmarshal_request(openapi_request) + request_unmarshalled.raise_for_errors() + response = web.Response( + body=self.OPENID_LOGO, + content_type="image/gif", + ) + openapi_response = AIOHTTPOpenAPIWebResponse(response) + response_unmarshalled = openapi.unmarshal_response( + openapi_request, openapi_response + ) + response_unmarshalled.raise_for_errors() + return response + + async def post(self): + request_body = await self.request.read() + openapi_request = AIOHTTPOpenAPIWebRequest( + self.request, body=request_body + ) + request_unmarshalled = openapi.unmarshal_request(openapi_request) + request_unmarshalled.raise_for_errors() + response = web.Response(status=201) + openapi_response = AIOHTTPOpenAPIWebResponse(response) + response_unmarshalled = openapi.unmarshal_response( + openapi_request, openapi_response + ) + response_unmarshalled.raise_for_errors() + return response diff --git a/tests/integration/contrib/aiohttp/test_aiohttp_project.py b/tests/integration/contrib/aiohttp/test_aiohttp_project.py new file mode 100644 index 00000000..54f7297d --- /dev/null +++ b/tests/integration/contrib/aiohttp/test_aiohttp_project.py @@ -0,0 +1,79 @@ +import os +import sys +from base64 import b64encode +from io import BytesIO + +import pytest + + +@pytest.fixture(autouse=True, scope="session") +def project_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, project_dir) + yield + sys.path.remove(project_dir) + + +@pytest.fixture +def app(project_setup): + from aiohttpproject.__main__ import get_app + + return get_app() + + +@pytest.fixture +async def client(app, aiohttp_client): + return await aiohttp_client(app) + + +class BaseTestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetPhotoView(BaseTestPetstore): + async def test_get_valid(self, client, data_gif): + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Host": "petstore.swagger.io", + } + + cookies = {"user": "1"} + response = await client.get( + "/v1/pets/1/photo", + headers=headers, + cookies=cookies, + ) + + assert await response.content.read() == data_gif + assert response.status == 200 + + async def test_post_valid(self, client, data_gif): + content_type = "image/gif" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + "Host": "petstore.swagger.io", + } + data = { + "file": BytesIO(data_gif), + } + + cookies = {"user": "1"} + response = await client.post( + "/v1/pets/1/photo", + headers=headers, + data=data, + cookies=cookies, + ) + + assert not await response.text() + assert response.status == 201 diff --git a/tests/integration/contrib/aiohttp/test_aiohttp_validation.py b/tests/integration/contrib/aiohttp/test_aiohttp_validation.py new file mode 100644 index 00000000..134e530d --- /dev/null +++ b/tests/integration/contrib/aiohttp/test_aiohttp_validation.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest import mock + +import pytest + +if TYPE_CHECKING: + from aiohttp.test_utils import TestClient + + +async def test_aiohttp_integration_valid_input(client: TestClient): + # Given + given_query_string = { + "q": "string", + } + given_headers = { + "content-type": "application/json", + "Host": "localhost", + } + given_data = {"param1": 1} + expected_status_code = 200 + expected_response_data = {"data": "data"} + # When + response = await client.post( + "/browse/12/", + params=given_query_string, + json=given_data, + headers=given_headers, + ) + response_data = await response.json() + # Then + assert response.status == expected_status_code + assert response_data == expected_response_data + + +async def test_aiohttp_integration_invalid_server(client: TestClient, request): + if "no_validation" in request.node.name: + pytest.skip("No validation for given handler.") + # Given + given_query_string = { + "q": "string", + } + given_headers = { + "content-type": "application/json", + "Host": "petstore.swagger.io", + } + given_data = {"param1": 1} + expected_status_code = 400 + expected_response_data = { + "errors": [ + { + "message": ( + "Server not found for " + "http://petstore.swagger.io/browse/12/" + ), + } + ] + } + # When + response = await client.post( + "/browse/12/", + params=given_query_string, + json=given_data, + headers=given_headers, + ) + response_data = await response.json() + # Then + assert response.status == expected_status_code + assert response_data == expected_response_data + + +async def test_aiohttp_integration_invalid_input( + client: TestClient, response_getter, request +): + if "no_validation" in request.node.name: + pytest.skip("No validation for given handler.") + # Given + given_query_string = { + "q": "string", + } + given_headers = { + "content-type": "application/json", + "Host": "localhost", + } + given_data = {"param1": "string"} + response_getter.return_value = {"data": 1} + expected_status_code = 400 + expected_response_data = {"errors": [{"message": mock.ANY}]} + # When + response = await client.post( + "/browse/12/", + params=given_query_string, + json=given_data, + headers=given_headers, + ) + response_data = await response.json() + # Then + assert response.status == expected_status_code + assert response_data == expected_response_data diff --git a/openapi_core/schema/licenses/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/__init__.py similarity index 100% rename from openapi_core/schema/licenses/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py b/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py new file mode 100644 index 00000000..9359e7d1 --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/auth.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from rest_framework import authentication +from rest_framework import exceptions + + +class SimpleAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + username = request.META.get("X_USERNAME") + if not username: + return None + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("No such user") + + return (user, None) diff --git a/openapi_core/schema/links/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/__init__.py similarity index 100% rename from openapi_core/schema/links/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/pets/__init__.py diff --git a/openapi_core/schema/media_types/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/migrations/__init__.py similarity index 100% rename from openapi_core/schema/media_types/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/pets/migrations/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py new file mode 100644 index 00000000..1cdb3c4e --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/pets/views.py @@ -0,0 +1,124 @@ +from base64 import b64decode + +from django.http import FileResponse +from django.http import HttpResponse +from django.http import JsonResponse +from rest_framework.views import APIView + + +class PetListView(APIView): + def get(self, request): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.query == { + "page": 1, + "limit": 12, + "search": "", + } + data = [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ] + response_dict = { + "data": data, + } + django_response = JsonResponse(response_dict) + django_response["X-Rate-Limit"] = "12" + + return django_response + + def post(self, request): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.cookie == { + "user": 1, + } + assert request.openapi.parameters.header == { + "api-key": "12345", + } + assert request.openapi.body.__class__.__name__ == "PetCreate" + assert request.openapi.body.name in ["Cat", "Bird"] + if request.openapi.body.name == "Cat": + assert request.openapi.body.ears.__class__.__name__ == "Ears" + assert request.openapi.body.ears.healthy is True + if request.openapi.body.name == "Bird": + assert request.openapi.body.wings.__class__.__name__ == "Wings" + assert request.openapi.body.wings.healthy is True + + django_response = HttpResponse(status=201) + django_response["X-Rate-Limit"] = "12" + + return django_response + + @staticmethod + def get_extra_actions(): + return [] + + +class PetDetailView(APIView): + def get(self, request, petId): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.path == { + "petId": 12, + } + data = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + response_dict = { + "data": data, + } + django_response = JsonResponse(response_dict) + django_response["X-Rate-Limit"] = "12" + return django_response + + @staticmethod + def get_extra_actions(): + return [] + + +class PetPhotoView(APIView): + OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" + ) + + def get(self, request, petId): + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters.path == { + "petId": 12, + } + django_response = FileResponse( + [self.OPENID_LOGO], + content_type="image/gif", + ) + return django_response + + def post(self, request, petId): + assert request.openapi + assert not request.openapi.errors + + # implement file upload here + + django_response = HttpResponse(status=201) + + return django_response + + @staticmethod + def get_extra_actions(): + return [] diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/settings.py b/tests/integration/contrib/django/data/v3.0/djangoproject/settings.py new file mode 100644 index 00000000..b50d4884 --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/settings.py @@ -0,0 +1,127 @@ +""" +Django settings for djangoproject project. + +Generated by 'django-admin startproject' using Django 2.2.18. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.2/ref/settings/ +""" + +import os +from pathlib import Path + +import yaml +from jsonschema_path import SchemaPath + +from openapi_core import OpenAPI + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "9=z^yj5yo%g_dyvgdzbceyph^nae)91lq(7^!qqmr1t9wi8b^=" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["petstore.swagger.io", "staging.gigantic-server.com"] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "openapi_core.contrib.django.middlewares.DjangoOpenAPIMiddleware", +] + +ROOT_URLCONF = "djangoproject.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "djangoproject.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [] + + +# Internationalization +# https://docs.djangoproject.com/en/2.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ + +STATIC_URL = "/static/" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "djangoproject.auth.SimpleAuthentication", + ] +} + +OPENAPI_SPEC_PATH = Path("tests/integration/data/v3.0/petstore.yaml") + +OPENAPI_SPEC_DICT = yaml.load(OPENAPI_SPEC_PATH.read_text(), yaml.Loader) + +OPENAPI_SPEC = SchemaPath.from_dict(OPENAPI_SPEC_DICT) + +OPENAPI = OpenAPI(OPENAPI_SPEC) diff --git a/openapi_core/schema/operations/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/__init__.py similarity index 100% rename from openapi_core/schema/operations/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/status/__init__.py diff --git a/openapi_core/schema/parameters/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/migrations/__init__.py similarity index 100% rename from openapi_core/schema/parameters/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/status/migrations/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py b/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py new file mode 100644 index 00000000..10d87749 --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/status/views.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from django.http import HttpResponse +from jsonschema_path import SchemaPath + +from openapi_core.contrib.django.decorators import DjangoOpenAPIViewDecorator + +check_minimal_spec = DjangoOpenAPIViewDecorator.from_spec( + SchemaPath.from_file_path( + Path("tests/integration/data/v3.0/minimal_with_servers.yaml") + ) +) + + +@check_minimal_spec +def get_status(request): + return HttpResponse("OK") diff --git a/openapi_core/schema/paths/__init__.py b/tests/integration/contrib/django/data/v3.0/djangoproject/tags/__init__.py similarity index 100% rename from openapi_core/schema/paths/__init__.py rename to tests/integration/contrib/django/data/v3.0/djangoproject/tags/__init__.py diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/tags/views.py b/tests/integration/contrib/django/data/v3.0/djangoproject/tags/views.py new file mode 100644 index 00000000..d822b4ff --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/tags/views.py @@ -0,0 +1,13 @@ +from django.http import HttpResponse +from rest_framework.views import APIView + + +class TagListView(APIView): + def get(self, request): + assert request.openapi + assert not request.openapi.errors + return HttpResponse("success") + + @staticmethod + def get_extra_actions(): + return [] diff --git a/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py new file mode 100644 index 00000000..be4e9781 --- /dev/null +++ b/tests/integration/contrib/django/data/v3.0/djangoproject/urls.py @@ -0,0 +1,57 @@ +"""djangotest URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include +from django.urls import path +from djangoproject.pets.views import PetDetailView +from djangoproject.pets.views import PetListView +from djangoproject.pets.views import PetPhotoView +from djangoproject.status.views import get_status +from djangoproject.tags.views import TagListView + +urlpatterns = [ + path("admin/", admin.site.urls), + path( + "api-auth/", + include("rest_framework.urls", namespace="rest_framework"), + ), + path( + "v1/pets", + PetListView.as_view(), + name="pet_list_view", + ), + path( + "v1/pets/", + PetDetailView.as_view(), + name="pet_detail_view", + ), + path( + "v1/pets//photo", + PetPhotoView.as_view(), + name="pet_photo_view", + ), + path( + "v1/tags", + TagListView.as_view(), + name="tag_list_view", + ), + path( + "status", + get_status, + name="get_status_view", + ), +] diff --git a/tests/integration/contrib/django/test_django_project.py b/tests/integration/contrib/django/test_django_project.py new file mode 100644 index 00000000..8a0697e1 --- /dev/null +++ b/tests/integration/contrib/django/test_django_project.py @@ -0,0 +1,462 @@ +import os +import sys +from base64 import b64encode +from json import dumps +from unittest import mock + +import pytest +from django.test.utils import override_settings + + +class BaseTestDjangoProject: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + @pytest.fixture(autouse=True, scope="module") + def django_setup(self): + directory = os.path.abspath(os.path.dirname(__file__)) + django_project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, django_project_dir) + with mock.patch.dict( + os.environ, + { + "DJANGO_SETTINGS_MODULE": "djangoproject.settings", + }, + ): + import django + + django.setup() + yield + sys.path.remove(django_project_dir) + + @pytest.fixture + def client(self): + from django.test import Client + + return Client() + + +class TestPetListView(BaseTestDjangoProject): + def test_get_no_required_param(self, client): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + + with pytest.warns(DeprecationWarning): + response = client.get("/v1/pets", **headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required query parameter: limit", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_valid(self, client): + data_json = { + "limit": 12, + } + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + + with pytest.warns(DeprecationWarning): + response = client.get("/v1/pets", data_json, **headers) + + expected_data = { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + assert response.status_code == 200 + assert response.json() == expected_data + + def test_post_server_invalid(self, client): + headers = { + "HTTP_HOST": "petstore.swagger.io", + } + response = client.post("/v1/pets", **headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://petstore.swagger.io/v1/pets" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_required_header_param_missing(self, client): + client.cookies.load({"user": 1}) + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + content_type = "application/json" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "staging.gigantic-server.com", + } + response = client.post( + "/v1/pets", data_json, content_type, secure=True, **headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required header parameter: api-key", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_media_type_invalid(self, client): + client.cookies.load({"user": 1}) + data = "data" + content_type = "text/html" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "staging.gigantic-server.com", + "HTTP_API_KEY": self.api_key_encoded, + } + response = client.post( + "/v1/pets", data, content_type, secure=True, **headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. " + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json() == expected_data + + def test_post_required_cookie_param_missing(self, client): + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + content_type = "application/json" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "staging.gigantic-server.com", + "HTTP_API_KEY": self.api_key_encoded, + } + response = client.post( + "/v1/pets", data_json, content_type, secure=True, **headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required cookie parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + @pytest.mark.parametrize( + "data_json", + [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + { + "id": 12, + "name": "Bird", + "wings": { + "healthy": True, + }, + }, + ], + ) + def test_post_valid(self, client, data_json): + client.cookies.load({"user": 1}) + content_type = "application/json" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "staging.gigantic-server.com", + "HTTP_API_KEY": self.api_key_encoded, + } + response = client.post( + "/v1/pets", data_json, content_type, secure=True, **headers + ) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailView(BaseTestDjangoProject): + def test_get_server_invalid(self, client): + response = client.get("/v1/pets/12") + + expected_data = ( + b"You may need to add 'testserver' to ALLOWED_HOSTS." + ) + assert response.status_code == 400 + assert expected_data in response.content + + def test_get_unauthorized(self, client): + headers = { + "HTTP_HOST": "petstore.swagger.io", + } + response = client.get("/v1/pets/12", **headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 403, + "title": ( + "Security not found. Schemes not valid for any " + "requirement: [['petstore_auth']]" + ), + } + ] + } + assert response.status_code == 403 + assert response.json() == expected_data + + def test_delete_method_invalid(self, client): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + response = client.delete("/v1/pets/12", **headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 405, + "title": ( + "Operation delete not found for " + "http://petstore.swagger.io/v1/pets/12" + ), + } + ] + } + assert response.status_code == 405 + assert response.json() == expected_data + + def test_get_valid(self, client): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + response = client.get("/v1/pets/12", **headers) + + expected_data = { + "data": { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + } + assert response.status_code == 200 + assert response.json() == expected_data + + +class BaseTestDRF(BaseTestDjangoProject): + @pytest.fixture + def api_client(self): + from rest_framework.test import APIClient + + return APIClient() + + +class TestDRFPetListView(BaseTestDRF): + def test_post_valid(self, api_client): + api_client.cookies.load({"user": 1}) + content_type = "application/json" + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "staging.gigantic-server.com", + "HTTP_API_KEY": self.api_key_encoded, + } + response = api_client.post( + "/v1/pets", + dumps(data_json), + content_type=content_type, + secure=True, + **headers, + ) + + assert response.status_code == 201 + assert not response.content + + +class TestDRFTagListView(BaseTestDRF): + def test_get_response_invalid(self, client): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + response = client.get("/v1/tags", **headers) + + assert response.status_code == 415 + + def test_get_skip_response_validation(self, client): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + with override_settings(OPENAPI_RESPONSE_CLS=None): + response = client.get("/v1/tags", **headers) + + assert response.status_code == 200 + assert response.content == b"success" + + +class TestPetPhotoView(BaseTestDjangoProject): + def test_get_valid(self, client, data_gif): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + response = client.get("/v1/pets/12/photo", **headers) + + assert response.status_code == 200 + assert b"".join(list(response.streaming_content)) == data_gif + + def test_post_valid(self, client, data_gif): + client.cookies.load({"user": 1}) + content_type = "image/gif" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + "HTTP_API_KEY": self.api_key_encoded, + } + response = client.post( + "/v1/pets/12/photo", data_gif, content_type, **headers + ) + + assert response.status_code == 201 + assert not response.content + + +class TestStatusView(BaseTestDjangoProject): + + def test_get_valid(self, client, data_gif): + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + from django.conf import settings + + MIDDLEWARE = [ + v for v in settings.MIDDLEWARE if "openapi_core" not in v + ] + with override_settings(MIDDLEWARE=MIDDLEWARE): + response = client.get("/status", **headers) + + assert response.status_code == 200 + assert response.content.decode() == "OK" + + def test_post_valid(self, client): + data = {"key": "value"} + content_type = "application/json" + headers = { + "HTTP_AUTHORIZATION": "Basic testuser", + "HTTP_HOST": "petstore.swagger.io", + } + from django.conf import settings + + MIDDLEWARE = [ + v for v in settings.MIDDLEWARE if "openapi_core" not in v + ] + with override_settings(MIDDLEWARE=MIDDLEWARE): + response = client.post( + "/status", data=data, content_type=content_type, **headers + ) + + assert response.status_code == 405 # Method Not Allowed diff --git a/tests/integration/contrib/falcon/conftest.py b/tests/integration/contrib/falcon/conftest.py index 60ac8d65..93a0f7f2 100644 --- a/tests/integration/contrib/falcon/conftest.py +++ b/tests/integration/contrib/falcon/conftest.py @@ -1,8 +1,15 @@ -from falcon import Request, Response, RequestOptions, ResponseOptions +import os +import sys + +import pytest +from falcon import Request +from falcon import RequestOptions +from falcon import Response +from falcon import ResponseOptions from falcon.routing import DefaultRouter from falcon.status_codes import HTTP_200 +from falcon.testing import TestClient from falcon.testing import create_environ -import pytest @pytest.fixture @@ -12,6 +19,7 @@ def create_env(method, path, server_name): host=server_name, path=path, ) + return create_env @@ -24,28 +32,56 @@ def router(): @pytest.fixture def request_factory(environ_factory, router): - server_name = 'localhost' + server_name = "localhost" def create_request( - method, path, subdomain=None, query_string=None, - content_type='application/json'): + method, + path, + subdomain=None, + query_string=None, + content_type="application/json", + ): environ = environ_factory(method, path, server_name) options = RequestOptions() # return create_req(options=options, **environ) req = Request(environ, options) - resource, method_map, params, req.uri_template = router.find(path, req) return req + return create_request @pytest.fixture def response_factory(environ_factory): def create_response( - data, status_code=200, content_type='application/json'): + data, status_code=200, headers=None, content_type="application/json" + ): options = ResponseOptions() resp = Response(options) resp.body = data resp.content_type = content_type resp.status = HTTP_200 + resp.set_headers(headers or {}) return resp + return create_response + + +@pytest.fixture(autouse=True, scope="module") +def falcon_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + falcon_project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, falcon_project_dir) + yield + sys.path.remove(falcon_project_dir) + + +@pytest.fixture +def app(): + from falconproject.__main__ import app + + return app + + +@pytest.fixture +def client(app): + return TestClient(app) diff --git a/openapi_core/schema/properties/__init__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__init__.py similarity index 100% rename from openapi_core/schema/properties/__init__.py rename to tests/integration/contrib/falcon/data/v3.0/falconproject/__init__.py diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py new file mode 100644 index 00000000..ae71fcf0 --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/__main__.py @@ -0,0 +1,23 @@ +from falcon import App +from falcon import media +from falconproject.openapi import openapi_middleware +from falconproject.pets.resources import PetDetailResource +from falconproject.pets.resources import PetListResource +from falconproject.pets.resources import PetPhotoResource + +extra_handlers = { + "application/vnd.api+json": media.JSONHandler(), +} + +app = App(middleware=[openapi_middleware]) + +app.req_options.media_handlers.update(extra_handlers) +app.resp_options.media_handlers.update(extra_handlers) + +pet_list_resource = PetListResource() +pet_detail_resource = PetDetailResource() +pet_photo_resource = PetPhotoResource() + +app.add_route("/v1/pets", pet_list_resource) +app.add_route("/v1/pets/{petId}", pet_detail_resource) +app.add_route("/v1/pets/{petId}/photo", pet_photo_resource) diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py new file mode 100644 index 00000000..3fd65641 --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/openapi.py @@ -0,0 +1,14 @@ +from pathlib import Path + +import yaml +from jsonschema_path import SchemaPath + +from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +spec = SchemaPath.from_dict(spec_dict) +openapi_middleware = FalconOpenAPIMiddleware.from_spec( + spec, + extra_media_type_deserializers={}, +) diff --git a/openapi_core/schema/request_bodies/__init__.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/__init__.py similarity index 100% rename from openapi_core/schema/request_bodies/__init__.py rename to tests/integration/contrib/falcon/data/v3.0/falconproject/pets/__init__.py diff --git a/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py new file mode 100644 index 00000000..d6e903da --- /dev/null +++ b/tests/integration/contrib/falcon/data/v3.0/falconproject/pets/resources.py @@ -0,0 +1,107 @@ +from base64 import b64decode +from json import dumps + +from falcon.constants import MEDIA_JPEG +from falcon.constants import MEDIA_JSON +from falcon.status_codes import HTTP_200 +from falcon.status_codes import HTTP_201 + + +class PetListResource: + def on_get(self, request, response): + assert request.context.openapi + assert not request.context.openapi.errors + if "ids" in request.params: + assert request.context.openapi.parameters.query == { + "page": 1, + "limit": 2, + "search": "", + "ids": [1, 2], + } + else: + assert request.context.openapi.parameters.query == { + "page": 1, + "limit": 12, + "search": "", + } + data = [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ] + response.status = HTTP_200 + response.content_type = MEDIA_JSON + response.text = dumps({"data": data}) + response.set_header("X-Rate-Limit", "12") + + def on_post(self, request, response): + assert request.context.openapi + assert not request.context.openapi.errors + assert request.context.openapi.parameters.cookie == { + "user": 1, + } + assert request.context.openapi.parameters.header == { + "api-key": "12345", + } + assert request.context.openapi.body.__class__.__name__ == "PetCreate" + assert request.context.openapi.body.name in ["Cat", "Bird"] + if request.context.openapi.body.name == "Cat": + assert ( + request.context.openapi.body.ears.__class__.__name__ == "Ears" + ) + assert request.context.openapi.body.ears.healthy is True + if request.context.openapi.body.name == "Bird": + assert ( + request.context.openapi.body.wings.__class__.__name__ + == "Wings" + ) + assert request.context.openapi.body.wings.healthy is True + + response.status = HTTP_201 + response.set_header("X-Rate-Limit", "12") + + +class PetDetailResource: + def on_get(self, request, response, petId=None): + assert petId == "12" + assert request.context.openapi + assert not request.context.openapi.errors + assert request.context.openapi.parameters.path == { + "petId": 12, + } + data = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + response.status = HTTP_200 + response.content_type = MEDIA_JSON + response.text = dumps({"data": data}) + response.set_header("X-Rate-Limit", "12") + + +class PetPhotoResource: + OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" + ) + + def on_get(self, request, response, petId=None): + response.content_type = MEDIA_JPEG + response.stream = [self.OPENID_LOGO] + + def on_post(self, request, response, petId=None): + data = request.stream.read() + assert data == self.OPENID_LOGO + response.status = HTTP_201 diff --git a/tests/integration/contrib/falcon/test_falcon_middlewares.py b/tests/integration/contrib/falcon/test_falcon_middlewares.py deleted file mode 100644 index fbed4339..00000000 --- a/tests/integration/contrib/falcon/test_falcon_middlewares.py +++ /dev/null @@ -1,204 +0,0 @@ -from json import dumps - -from falcon import API -from falcon.testing import TestClient -import pytest - -from openapi_core.contrib.falcon.middlewares import FalconOpenAPIMiddleware -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.datatypes import RequestParameters - - -class TestFalconOpenAPIMiddleware(object): - - view_response_callable = None - - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/falcon_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - @pytest.fixture - def middleware(self, spec): - return FalconOpenAPIMiddleware.from_spec(spec) - - @pytest.fixture - def app(self, middleware): - return API(middleware=[middleware]) - - @pytest.yield_fixture - def client(self, app): - return TestClient(app) - - @pytest.fixture - def view_response(self): - def view_response(*args, **kwargs): - return self.view_response_callable(*args, **kwargs) - return view_response - - @pytest.fixture(autouse=True) - def details_view(self, app, view_response): - class BrowseDetailResource(object): - def on_get(self, *args, **kwargs): - return view_response(*args, **kwargs) - - resource = BrowseDetailResource() - app.add_route("/browse/{id}", resource) - return resource - - @pytest.fixture(autouse=True) - def list_view(self, app, view_response): - class BrowseListResource(object): - def on_get(self, *args, **kwargs): - return view_response(*args, **kwargs) - - resource = BrowseListResource() - app.add_route("/browse", resource) - return resource - - def test_invalid_content_type(self, client): - def view_response_callable(request, response, id): - from falcon.constants import MEDIA_HTML - from falcon.status_codes import HTTP_200 - assert request.openapi - assert not request.openapi.errors - assert request.openapi.parameters == RequestParameters(path={ - 'id': 12, - }) - response.content_type = MEDIA_HTML - response.status = HTTP_200 - response.body = 'success' - self.view_response_callable = view_response_callable - headers = {'Content-Type': 'application/json'} - result = client.simulate_get( - '/browse/12', host='localhost', headers=headers) - - assert result.json == { - 'errors': [ - { - 'class': ( - "" - ), - 'status': 415, - 'title': ( - 'Content for following mimetype not found: text/html' - ) - } - ] - } - - def test_server_error(self, client): - headers = {'Content-Type': 'application/json'} - result = client.simulate_get( - '/browse/12', host='localhost', headers=headers, protocol='https') - - expected_data = { - 'errors': [ - { - 'class': ( - "" - ), - 'status': 400, - 'title': ( - 'Server not found for ' - 'https://localhost/browse/12' - ), - } - ] - } - assert result.status_code == 400 - assert result.json == expected_data - - def test_operation_error(self, client): - headers = {'Content-Type': 'application/json'} - result = client.simulate_post( - '/browse/12', host='localhost', headers=headers) - - expected_data = { - 'errors': [ - { - 'class': ( - "" - ), - 'status': 405, - 'title': ( - 'Operation post not found for ' - 'http://localhost/browse/12' - ), - } - ] - } - assert result.status_code == 405 - assert result.json == expected_data - - def test_path_error(self, client): - headers = {'Content-Type': 'application/json'} - result = client.simulate_get( - '/browse', host='localhost', headers=headers) - - expected_data = { - 'errors': [ - { - 'class': ( - "" - ), - 'status': 404, - 'title': ( - 'Path not found for ' - 'http://localhost/browse' - ), - } - ] - } - assert result.status_code == 404 - assert result.json == expected_data - - def test_endpoint_error(self, client): - headers = {'Content-Type': 'application/json'} - result = client.simulate_get( - '/browse/invalidparameter', host='localhost', headers=headers) - - expected_data = { - 'errors': [ - { - 'class': ( - "" - ), - 'status': 400, - 'title': ( - "Failed to cast value invalidparameter to type integer" - ) - } - ] - } - assert result.json == expected_data - - def test_valid(self, client): - def view_response_callable(request, response, id): - from falcon.constants import MEDIA_JSON - from falcon.status_codes import HTTP_200 - assert request.openapi - assert not request.openapi.errors - assert request.openapi.parameters == RequestParameters(path={ - 'id': 12, - }) - response.status = HTTP_200 - response.content_type = MEDIA_JSON - response.body = dumps({ - 'data': 'data', - }) - self.view_response_callable = view_response_callable - - headers = {'Content-Type': 'application/json'} - result = client.simulate_get( - '/browse/12', host='localhost', headers=headers) - - assert result.status_code == 200 - assert result.json == { - 'data': 'data', - } diff --git a/tests/integration/contrib/falcon/test_falcon_project.py b/tests/integration/contrib/falcon/test_falcon_project.py new file mode 100644 index 00000000..252e0d6a --- /dev/null +++ b/tests/integration/contrib/falcon/test_falcon_project.py @@ -0,0 +1,477 @@ +from base64 import b64encode +from json import dumps + +import pytest +from urllib3 import encode_multipart_formdata + + +class BaseTestFalconProject: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetListResource(BaseTestFalconProject): + def test_get_no_required_param(self, client): + headers = { + "Content-Type": "application/json", + } + + with pytest.warns(DeprecationWarning): + response = client.simulate_get( + "/v1/pets", host="petstore.swagger.io", headers=headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required query parameter: limit", + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_get_valid(self, client): + headers = { + "Content-Type": "application/json", + } + query_string = "limit=12" + + with pytest.warns(DeprecationWarning): + response = client.simulate_get( + "/v1/pets", + host="petstore.swagger.io", + headers=headers, + query_string=query_string, + ) + + assert response.status_code == 200 + assert response.json == { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + + def test_get_valid_multiple_ids(self, client): + headers = { + "Content-Type": "application/json", + } + query_string = "limit=2&ids=1&ids=2" + + with pytest.warns(DeprecationWarning): + response = client.simulate_get( + "/v1/pets", + host="petstore.swagger.io", + headers=headers, + query_string=query_string, + ) + + assert response.status_code == 200 + assert response.json == { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + + def test_post_server_invalid(self, client): + response = client.simulate_post( + "/v1/pets", + host="petstore.swagger.io", + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://petstore.swagger.io/v1/pets" + ), + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_post_required_header_param_missing(self, client): + cookies = {"user": 1} + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + "/v1/pets", + host="staging.gigantic-server.com", + headers=headers, + body=body, + cookies=cookies, + protocol="https", + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required header parameter: api-key", + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_post_media_type_invalid(self, client): + cookies = {"user": 1} + data_json = { + "data": "", + } + # noly 3 media types are supported by falcon by default: + # json, multipart and urlencoded + content_type = "application/vnd.api+json" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + "/v1/pets", + host="staging.gigantic-server.com", + headers=headers, + body=body, + cookies=cookies, + protocol="https", + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + f"{content_type}. " + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json == expected_data + + def test_post_required_cookie_param_missing(self, client): + content_type = "application/json" + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + "/v1/pets", + host="staging.gigantic-server.com", + headers=headers, + body=body, + protocol="https", + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required cookie parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + @pytest.mark.parametrize( + "data_json", + [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + { + "id": 12, + "name": "Bird", + "wings": { + "healthy": True, + }, + }, + ], + ) + def test_post_valid(self, client, data_json): + cookies = {"user": 1} + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + body = dumps(data_json) + + response = client.simulate_post( + "/v1/pets", + host="staging.gigantic-server.com", + headers=headers, + body=body, + cookies=cookies, + protocol="https", + ) + + assert response.status_code == 201 + assert not response.content + + @pytest.mark.xfail( + reason="falcon multipart form serialization unsupported", + strict=True, + ) + def test_post_multipart_valid(self, client, data_gif): + cookies = {"user": 1} + auth = "authuser" + fields = { + "name": "Cat", + "address": ( + "aaddress.json", + dumps(dict(city="Warsaw")), + "application/json", + ), + "photo": ( + "photo.jpg", + data_gif, + "image/jpeg", + ), + } + body, content_type_header = encode_multipart_formdata(fields) + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": content_type_header, + } + + response = client.simulate_post( + "/v1/pets", + host="staging.gigantic-server.com", + headers=headers, + body=body, + cookies=cookies, + protocol="https", + ) + + assert response.status_code == 200 + + +class TestPetDetailResource: + def test_get_server_invalid(self, client): + headers = {"Content-Type": "application/json"} + + response = client.simulate_get("/v1/pets/12", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://falconframework.org/v1/pets/12" + ), + } + ] + } + assert response.status_code == 400 + assert response.json == expected_data + + def test_get_path_invalid(self, client): + headers = {"Content-Type": "application/json"} + + response = client.simulate_get( + "/v1/pet/invalid", host="petstore.swagger.io", headers=headers + ) + + assert response.status_code == 404 + + def test_get_unauthorized(self, client): + headers = {"Content-Type": "application/json"} + + response = client.simulate_get( + "/v1/pets/12", host="petstore.swagger.io", headers=headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 403, + "title": ( + "Security not found. Schemes not valid for any " + "requirement: [['petstore_auth']]" + ), + } + ] + } + assert response.status_code == 403 + assert response.json == expected_data + + def test_get_valid(self, client): + auth = "authuser" + content_type = "application/json" + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": content_type, + } + + response = client.simulate_get( + "/v1/pets/12", host="petstore.swagger.io", headers=headers + ) + + assert response.status_code == 200 + + def test_delete_method_invalid(self, client): + auth = "authuser" + content_type = "application/json" + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": content_type, + } + + response = client.simulate_delete( + "/v1/pets/12", host="petstore.swagger.io", headers=headers + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 405, + "title": ( + "Operation delete not found for " + "http://petstore.swagger.io/v1/pets/12" + ), + } + ] + } + assert response.status_code == 405 + assert response.json == expected_data + + +class TestPetPhotoResource(BaseTestFalconProject): + def test_get_valid(self, client, data_gif): + cookies = {"user": 1} + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + + response = client.simulate_get( + "/v1/pets/1/photo", + host="petstore.swagger.io", + headers=headers, + cookies=cookies, + ) + + assert response.content == data_gif + assert response.status_code == 200 + + @pytest.mark.xfail( + reason="falcon request binary handler not implemented", + strict=True, + ) + def test_post_valid(self, client, data_gif): + cookies = {"user": 1} + content_type = "image/jpeg" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + + response = client.simulate_post( + "/v1/pets/1/photo", + host="petstore.swagger.io", + headers=headers, + body=data_gif, + cookies=cookies, + ) + + assert not response.content + assert response.status_code == 201 diff --git a/tests/integration/contrib/falcon/test_falcon_validation.py b/tests/integration/contrib/falcon/test_falcon_validation.py deleted file mode 100644 index 9e5466cf..00000000 --- a/tests/integration/contrib/falcon/test_falcon_validation.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from openapi_core.contrib.falcon.requests import FalconOpenAPIRequestFactory -from openapi_core.contrib.falcon.responses import FalconOpenAPIResponseFactory -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -class TestFalconOpenAPIValidation(object): - - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/falcon/data/v3.0/falcon_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - def test_response_validator_path_pattern(self, - spec, - request_factory, - response_factory): - validator = ResponseValidator(spec) - request = request_factory('GET', '/browse/12', subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory.create(request) - response = response_factory('{"data": "data"}', status_code=200) - openapi_response = FalconOpenAPIResponseFactory.create(response) - result = validator.validate(openapi_request, openapi_response) - assert not result.errors - - def test_request_validator_path_pattern(self, spec, request_factory): - validator = RequestValidator(spec) - request = request_factory('GET', '/browse/12', subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory.create(request) - result = validator.validate(openapi_request) - assert not result.errors - - def test_request_validator_with_query(self, spec, request_factory): - validator = RequestValidator(spec) - request = request_factory('GET', '/browse/12', - query_string='detail_level=2', - subdomain='kb') - openapi_request = FalconOpenAPIRequestFactory.create(request) - result = validator.validate(openapi_request) - assert not result.errors diff --git a/openapi_core/schema/responses/__init__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__init__.py similarity index 100% rename from openapi_core/schema/responses/__init__.py rename to tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__init__.py diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py new file mode 100644 index 00000000..916cd2cd --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__main__.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from fastapiproject.openapi import openapi +from fastapiproject.routers import pets + +from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware + +app = FastAPI() +app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi) +app.include_router(pets.router) diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py new file mode 100644 index 00000000..4ca6d9fa --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/openapi.py @@ -0,0 +1,9 @@ +from pathlib import Path + +import yaml + +from openapi_core import OpenAPI + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +openapi = OpenAPI.from_dict(spec_dict) diff --git a/openapi_core/schema/schemas/__init__.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/__init__.py similarity index 100% rename from openapi_core/schema/schemas/__init__.py rename to tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/__init__.py diff --git a/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py new file mode 100644 index 00000000..d4b763b9 --- /dev/null +++ b/tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/pets.py @@ -0,0 +1,109 @@ +from base64 import b64decode + +from fastapi import APIRouter +from fastapi import Body +from fastapi import Request +from fastapi import Response +from fastapi import status + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" +) + + +router = APIRouter( + prefix="/v1/pets", + tags=["pets"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("") +async def list_pets(request: Request, response: Response): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + assert request.scope["openapi"].parameters.query == { + "page": 1, + "limit": 12, + "search": "", + } + data = [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ] + response.headers["X-Rate-Limit"] = "12" + return {"data": data} + + +@router.post("") +async def create_pet(request: Request): + assert request.scope["openapi"].parameters.cookie == { + "user": 1, + } + assert request.scope["openapi"].parameters.header == { + "api-key": "12345", + } + assert request.scope["openapi"].body.__class__.__name__ == "PetCreate" + assert request.scope["openapi"].body.name in ["Cat", "Bird"] + if request.scope["openapi"].body.name == "Cat": + assert request.scope["openapi"].body.ears.__class__.__name__ == "Ears" + assert request.scope["openapi"].body.ears.healthy is True + if request.scope["openapi"].body.name == "Bird": + assert ( + request.scope["openapi"].body.wings.__class__.__name__ == "Wings" + ) + assert request.scope["openapi"].body.wings.healthy is True + + headers = { + "X-Rate-Limit": "12", + } + return Response(status_code=status.HTTP_201_CREATED, headers=headers) + + +@router.get("/{petId}") +async def detail_pet(request: Request, response: Response): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + assert request.scope["openapi"].parameters.path == { + "petId": 12, + } + data = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + response.headers["X-Rate-Limit"] = "12" + return { + "data": data, + } + + +@router.get("/{petId}/photo") +async def download_pet_photo(): + return Response(content=OPENID_LOGO, media_type="image/gif") + + +@router.post("/{petId}/photo") +async def upload_pet_photo( + image: Annotated[bytes, Body(media_type="image/jpg")], +): + assert image == OPENID_LOGO + return Response(status_code=status.HTTP_201_CREATED) diff --git a/tests/integration/contrib/fastapi/test_fastapi_project.py b/tests/integration/contrib/fastapi/test_fastapi_project.py new file mode 100644 index 00000000..242613bc --- /dev/null +++ b/tests/integration/contrib/fastapi/test_fastapi_project.py @@ -0,0 +1,383 @@ +import os +import sys +from base64 import b64encode + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True, scope="module") +def project_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, project_dir) + yield + sys.path.remove(project_dir) + + +@pytest.fixture +def app(): + from fastapiproject.__main__ import app + + return app + + +@pytest.fixture +def client(app): + return TestClient(app, base_url="http://petstore.swagger.io") + + +class BaseTestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetListEndpoint(BaseTestPetstore): + def test_get_no_required_param(self, client): + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get("/v1/pets", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required query parameter: limit", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_valid(self, client): + data_json = { + "limit": 12, + } + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get( + "/v1/pets", + params=data_json, + headers=headers, + ) + + expected_data = { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + assert response.status_code == 200 + assert response.json() == expected_data + + def test_post_server_invalid(self, client): + response = client.post("/v1/pets") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://petstore.swagger.io/v1/pets" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_required_header_param_missing(self, client): + client.cookies.set("user", "1") + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required header parameter: api-key", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_media_type_invalid(self, client): + client.cookies.set("user", "1") + content = "data" + content_type = "text/html" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + content=content, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. " + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json() == expected_data + + def test_post_required_cookie_param_missing(self, client): + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required cookie parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + @pytest.mark.parametrize( + "data_json", + [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + { + "id": 12, + "name": "Bird", + "wings": { + "healthy": True, + }, + }, + ], + ) + def test_post_valid(self, client, data_json): + client.cookies.set("user", "1") + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailEndpoint(BaseTestPetstore): + def test_get_server_invalid(self, client): + response = client.get("http://testserver/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " "http://testserver/v1/pets/12" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_unauthorized(self, client): + response = client.get("/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 403, + "title": ( + "Security not found. Schemes not valid for any " + "requirement: [['petstore_auth']]" + ), + } + ] + } + assert response.status_code == 403 + assert response.json() == expected_data + + def test_delete_method_invalid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.delete("/v1/pets/12", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 405, + "title": ( + "Operation delete not found for " + "http://petstore.swagger.io/v1/pets/12" + ), + } + ] + } + assert response.status_code == 405 + assert response.json() == expected_data + + def test_get_valid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.get("/v1/pets/12", headers=headers) + + expected_data = { + "data": { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + } + assert response.status_code == 200 + assert response.json() == expected_data + + +class TestPetPhotoEndpoint(BaseTestPetstore): + def test_get_valid(self, client, data_gif): + client.cookies.set("user", "1") + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + + response = client.get( + "/v1/pets/1/photo", + headers=headers, + ) + + assert response.content == data_gif + assert response.status_code == 200 + + def test_post_valid(self, client, data_gif): + client.cookies.set("user", "1") + content_type = "image/gif" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + + response = client.post( + "/v1/pets/1/photo", + headers=headers, + content=data_gif, + ) + + assert not response.text + assert response.status_code == 201 diff --git a/tests/integration/contrib/flask/conftest.py b/tests/integration/contrib/flask/conftest.py index 4e86bcdc..a89e729a 100644 --- a/tests/integration/contrib/flask/conftest.py +++ b/tests/integration/contrib/flask/conftest.py @@ -1,49 +1,37 @@ -from flask.wrappers import Request, Response import pytest -from werkzeug.routing import Map, Rule, Subdomain -from werkzeug.test import create_environ +from flask import Flask -@pytest.fixture -def environ_factory(): - return create_environ +@pytest.fixture(scope="session") +def schema_path(schema_path_factory): + specfile = "contrib/flask/data/v3.0/flask_factory.yaml" + return schema_path_factory.from_file(specfile) @pytest.fixture -def map(): - return Map([ - # Static URLs - Rule('/', endpoint='static/index'), - Rule('/about', endpoint='static/about'), - Rule('/help', endpoint='static/help'), - # Knowledge Base - Subdomain('kb', [ - Rule('/', endpoint='kb/index'), - Rule('/browse/', endpoint='kb/browse'), - Rule('/browse//', endpoint='kb/browse'), - Rule('/browse//', endpoint='kb/browse') - ]) - ], default_subdomain='www') +def app(app_factory): + return app_factory() @pytest.fixture -def request_factory(map, environ_factory): - server_name = 'localhost' +def client(client_factory, app): + return client_factory(app) - def create_request(method, path, subdomain=None, query_string=None): - environ = environ_factory(query_string=query_string) - req = Request(environ) - urls = map.bind_to_environ( - environ, server_name=server_name, subdomain=subdomain) - req.url_rule, req.view_args = urls.match( - path, method, return_rule=True) - return req - return create_request +@pytest.fixture(scope="session") +def client_factory(): + def create(app): + return app.test_client() -@pytest.fixture -def response_factory(): - def create_response( - data, status_code=200, content_type='application/json'): - return Response(data, status=status_code, content_type=content_type) - return create_response + return create + + +@pytest.fixture(scope="session") +def app_factory(): + def create(root_path=None): + app = Flask("__main__", root_path=root_path) + app.config["DEBUG"] = True + app.config["TESTING"] = True + return app + + return create diff --git a/tests/integration/contrib/flask/data/v3.0/flask_factory.yaml b/tests/integration/contrib/flask/data/v3.0/flask_factory.yaml index 6ed6d563..17a195db 100644 --- a/tests/integration/contrib/flask/data/v3.0/flask_factory.yaml +++ b/tests/integration/contrib/flask/data/v3.0/flask_factory.yaml @@ -13,7 +13,70 @@ paths: description: the ID of the resource to retrieve schema: type: integer + - name: q + in: query + required: false + description: query key + schema: + type: string get: + responses: + 404: + description: Return error. + content: + text/html: + schema: + type: string + 200: + description: Return the resource. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: string + headers: + X-Rate-Limit: + description: Rate limit + schema: + type: integer + required: true + default: + description: Return errors. + content: + application/json: + schema: + type: object + required: + - errors + properties: + errors: + type: array + items: + type: object + properties: + title: + type: string + code: + type: string + message: + type: string + post: + requestBody: + description: request data + required: True + content: + application/json: + schema: + type: object + required: + - param1 + properties: + param1: + type: integer responses: 200: description: Return the resource. @@ -26,6 +89,12 @@ paths: properties: data: type: string + headers: + X-Rate-Limit: + description: Rate limit + schema: + type: integer + required: true default: description: Return errors. content: diff --git a/openapi_core/schema/security_requirements/__init__.py b/tests/integration/contrib/flask/data/v3.0/flaskproject/__init__.py similarity index 100% rename from openapi_core/schema/security_requirements/__init__.py rename to tests/integration/contrib/flask/data/v3.0/flaskproject/__init__.py diff --git a/tests/integration/contrib/flask/data/v3.0/flaskproject/__main__.py b/tests/integration/contrib/flask/data/v3.0/flaskproject/__main__.py new file mode 100644 index 00000000..dc95cdc8 --- /dev/null +++ b/tests/integration/contrib/flask/data/v3.0/flaskproject/__main__.py @@ -0,0 +1,11 @@ +from flask import Flask +from flaskproject.openapi import openapi +from flaskproject.pets.views import PetPhotoView + +app = Flask(__name__) + +app.add_url_rule( + "/v1/pets//photo", + view_func=PetPhotoView.as_view("pet_photo", openapi), + methods=["GET", "POST"], +) diff --git a/tests/integration/contrib/flask/data/v3.0/flaskproject/openapi.py b/tests/integration/contrib/flask/data/v3.0/flaskproject/openapi.py new file mode 100644 index 00000000..4ca6d9fa --- /dev/null +++ b/tests/integration/contrib/flask/data/v3.0/flaskproject/openapi.py @@ -0,0 +1,9 @@ +from pathlib import Path + +import yaml + +from openapi_core import OpenAPI + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +openapi = OpenAPI.from_dict(spec_dict) diff --git a/openapi_core/schema/security_schemes/__init__.py b/tests/integration/contrib/flask/data/v3.0/flaskproject/pets/__init__.py similarity index 100% rename from openapi_core/schema/security_schemes/__init__.py rename to tests/integration/contrib/flask/data/v3.0/flaskproject/pets/__init__.py diff --git a/tests/integration/contrib/flask/data/v3.0/flaskproject/pets/views.py b/tests/integration/contrib/flask/data/v3.0/flaskproject/pets/views.py new file mode 100644 index 00000000..f9b55a03 --- /dev/null +++ b/tests/integration/contrib/flask/data/v3.0/flaskproject/pets/views.py @@ -0,0 +1,28 @@ +from base64 import b64decode +from io import BytesIO + +from flask import Response +from flask import request +from flask.helpers import send_file + +from openapi_core.contrib.flask.views import FlaskOpenAPIView + + +class PetPhotoView(FlaskOpenAPIView): + OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" + ) + + def get(self, petId): + fp = BytesIO(self.OPENID_LOGO) + return send_file(fp, mimetype="image/gif") + + def post(self, petId): + assert request.data == self.OPENID_LOGO + return Response(status=201) diff --git a/tests/integration/contrib/flask/test_flask_decorator.py b/tests/integration/contrib/flask/test_flask_decorator.py index afa5ad20..91637b94 100644 --- a/tests/integration/contrib/flask/test_flask_decorator.py +++ b/tests/integration/contrib/flask/test_flask_decorator.py @@ -1,100 +1,102 @@ -from flask import Flask, make_response, jsonify import pytest +from flask import jsonify +from flask import make_response from openapi_core.contrib.flask.decorators import FlaskOpenAPIViewDecorator -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.datatypes import RequestParameters +from openapi_core.datatypes import Parameters -class TestFlaskOpenAPIDecorator(object): +@pytest.fixture(scope="session") +def decorator_factory(schema_path): + def create(**kwargs): + return FlaskOpenAPIViewDecorator.from_spec(schema_path, **kwargs) - view_response_callable = None + return create - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/flask/data/v3.0/flask_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - @pytest.fixture - def decorator(self, spec): - return FlaskOpenAPIViewDecorator.from_spec(spec) +@pytest.fixture(scope="session") +def view_factory(decorator_factory): + def create( + app, path, methods=None, view_response_callable=None, decorator=None + ): + decorator = decorator or decorator_factory() - @pytest.fixture - def app(self): - app = Flask("__main__") - app.config['DEBUG'] = True - app.config['TESTING'] = True - return app - - @pytest.yield_fixture - def client(self, app): - with app.test_client() as client: - with app.app_context(): - yield client - - @pytest.fixture - def view_response(self): - def view_response(*args, **kwargs): - return self.view_response_callable(*args, **kwargs) - return view_response - - @pytest.fixture(autouse=True) - def details_view(self, app, decorator, view_response): - @app.route("/browse//", methods=['GET', 'POST']) + @app.route(path, methods=methods) @decorator - def browse_details(*args, **kwargs): - return view_response(*args, **kwargs) - return browse_details + def view(*args, **kwargs): + return view_response_callable(*args, **kwargs) - @pytest.fixture(autouse=True) - def list_view(self, app, decorator, view_response): - @app.route("/browse/") - @decorator - def browse_list(*args, **kwargs): - return view_response(*args, **kwargs) - return browse_list + return view + + return create + + +class TestFlaskOpenAPIDecorator: + @pytest.fixture + def decorator(self, decorator_factory): + return decorator_factory() - def test_invalid_content_type(self, client): + def test_invalid_content_type(self, client, view_factory, app, decorator): def view_response_callable(*args, **kwargs): from flask.globals import request + assert request.openapi assert not request.openapi.errors - assert request.openapi.parameters == RequestParameters(path={ - 'id': 12, - }) - return make_response('success', 200) - self.view_response_callable = view_response_callable - result = client.get('/browse/12/') + assert request.openapi.parameters == Parameters( + path={ + "id": 12, + } + ) + resp = make_response("success", 200) + resp.headers["X-Rate-Limit"] = "12" + return resp + + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=view_response_callable, + decorator=decorator, + ) + result = client.get("/browse/12/") assert result.json == { - 'errors': [ + "errors": [ { - 'class': ( - "" + "class": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. Valid mimetypes: ['application/json']" ), - 'status': 415, - 'title': ( - 'Content for following mimetype not found: text/html' - ) } ] } - def test_server_error(self, client): - result = client.get('/browse/12/', base_url='https://localhost') + def test_server_error(self, client, view_factory, app, decorator): + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=None, + decorator=decorator, + ) + result = client.get("/browse/12/", base_url="https://localhost") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 400, - 'title': ( - 'Server not found for ' - 'https://localhost/browse/{id}/' + "status": 400, + "title": ( + "Server not found for " + "https://localhost/browse/{id}/" ), } ] @@ -102,20 +104,27 @@ def test_server_error(self, client): assert result.status_code == 400 assert result.json == expected_data - def test_operation_error(self, client): - result = client.post('/browse/12/') + def test_operation_error(self, client, view_factory, app, decorator): + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=None, + decorator=decorator, + ) + result = client.put("/browse/12/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 405, - 'title': ( - 'Operation post not found for ' - 'http://localhost/browse/{id}/' + "status": 405, + "title": ( + "Operation put not found for " + "http://localhost/browse/{id}/" ), } ] @@ -123,20 +132,25 @@ def test_operation_error(self, client): assert result.status_code == 405 assert result.json == expected_data - def test_path_error(self, client): - result = client.get('/browse/') + def test_path_error(self, client, view_factory, app, decorator): + view_factory( + app, + "/browse/", + view_response_callable=None, + decorator=decorator, + ) + result = client.get("/browse/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 404, - 'title': ( - 'Path not found for ' - 'http://localhost/browse/' + "status": 404, + "title": ( + "Path not found for " "http://localhost/browse/" ), } ] @@ -144,39 +158,147 @@ def test_path_error(self, client): assert result.status_code == 404 assert result.json == expected_data - def test_endpoint_error(self, client): - result = client.get('/browse/invalidparameter/') + def test_endpoint_error(self, client, view_factory, app, decorator): + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=None, + decorator=decorator, + ) + result = client.get("/browse/invalidparameter/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 400, - 'title': ( - "Failed to cast value invalidparameter to type integer" - ) + "status": 400, + "title": ( + "Failed to cast value to integer type: " + "invalidparameter" + ), } ] } assert result.json == expected_data - def test_valid(self, client): + def test_response_object_valid(self, client, view_factory, app, decorator): def view_response_callable(*args, **kwargs): from flask.globals import request + assert request.openapi assert not request.openapi.errors - assert request.openapi.parameters == RequestParameters(path={ - 'id': 12, - }) - return jsonify(data='data') - self.view_response_callable = view_response_callable + assert request.openapi.parameters == Parameters( + path={ + "id": 12, + } + ) + resp = jsonify(data="data") + resp.headers["X-Rate-Limit"] = "12" + return resp + + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=view_response_callable, + decorator=decorator, + ) - result = client.get('/browse/12/') + result = client.get("/browse/12/") assert result.status_code == 200 assert result.json == { - 'data': 'data', + "data": "data", } + + def test_response_skip_validation( + self, client, view_factory, app, decorator_factory + ): + def view_response_callable(*args, **kwargs): + from flask.globals import request + + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters == Parameters( + path={ + "id": 12, + } + ) + return make_response("success", 200) + + decorator = decorator_factory(response_cls=None) + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=view_response_callable, + decorator=decorator, + ) + + result = client.get("/browse/12/") + + assert result.status_code == 200 + assert result.text == "success" + + @pytest.mark.parametrize( + "response,expected_status,expected_headers", + [ + # ((body, status, headers)) response tuple + ( + ("Not found", 404, {"X-Rate-Limit": "12"}), + 404, + {"X-Rate-Limit": "12"}, + ), + # (body, status) response tuple + (("Not found", 404), 404, {}), + # (body, headers) response tuple + ( + ({"data": "data"}, {"X-Rate-Limit": "12"}), + 200, + {"X-Rate-Limit": "12"}, + ), + ], + ) + def test_tuple_valid( + self, + client, + view_factory, + app, + decorator, + response, + expected_status, + expected_headers, + ): + def view_response_callable(*args, **kwargs): + from flask.globals import request + + assert request.openapi + assert not request.openapi.errors + assert request.openapi.parameters == Parameters( + path={ + "id": 12, + } + ) + return response + + view_factory( + app, + "/browse//", + ["GET", "PUT"], + view_response_callable=view_response_callable, + decorator=decorator, + ) + + result = client.get("/browse/12/") + + assert result.status_code == expected_status + expected_body = response[0] + if isinstance(expected_body, str): + assert result.text == expected_body + else: + assert result.json == expected_body + assert dict(result.headers).items() >= expected_headers.items() diff --git a/tests/integration/contrib/flask/test_flask_project.py b/tests/integration/contrib/flask/test_flask_project.py new file mode 100644 index 00000000..ddeb9320 --- /dev/null +++ b/tests/integration/contrib/flask/test_flask_project.py @@ -0,0 +1,75 @@ +import os +import sys +from base64 import b64encode + +import pytest + + +@pytest.fixture(autouse=True, scope="module") +def flask_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + flask_project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, flask_project_dir) + yield + sys.path.remove(flask_project_dir) + + +@pytest.fixture +def app(): + from flaskproject.__main__ import app + + app.config["SERVER_NAME"] = "petstore.swagger.io" + app.config["DEBUG"] = True + app.config["TESTING"] = True + + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +class BaseTestFlaskProject: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetPhotoView(BaseTestFlaskProject): + def test_get_valid(self, client, data_gif): + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + + client.set_cookie("user", "1", domain="petstore.swagger.io") + response = client.get( + "/v1/pets/1/photo", + headers=headers, + ) + + assert response.get_data() == data_gif + assert response.status_code == 200 + + def test_post_valid(self, client, data_gif): + content_type = "image/gif" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + + client.set_cookie("user", "1", domain="petstore.swagger.io") + response = client.post( + "/v1/pets/1/photo", + headers=headers, + data=data_gif, + ) + + assert not response.text + assert response.status_code == 201 diff --git a/tests/integration/contrib/flask/test_flask_requests.py b/tests/integration/contrib/flask/test_flask_requests.py deleted file mode 100644 index 613f3168..00000000 --- a/tests/integration/contrib/flask/test_flask_requests.py +++ /dev/null @@ -1,74 +0,0 @@ -from six.moves.urllib.parse import urljoin -from werkzeug.datastructures import EnvironHeaders, ImmutableMultiDict - -from openapi_core.contrib.flask import FlaskOpenAPIRequest -from openapi_core.validation.request.datatypes import RequestParameters - - -class TestFlaskOpenAPIRequest(object): - - def test_simple(self, request_factory, request): - request = request_factory('GET', '/', subdomain='www') - - openapi_request = FlaskOpenAPIRequest(request) - - path = {} - query = ImmutableMultiDict([]) - headers = EnvironHeaders(request.environ) - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - urljoin(request.host_url, request.path) - assert openapi_request.body == request.data - assert openapi_request.mimetype == request.mimetype - - def test_multiple_values(self, request_factory, request): - request = request_factory( - 'GET', '/', subdomain='www', query_string='a=b&a=c') - - openapi_request = FlaskOpenAPIRequest(request) - - path = {} - query = ImmutableMultiDict([ - ('a', 'b'), ('a', 'c'), - ]) - headers = EnvironHeaders(request.environ) - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - urljoin(request.host_url, request.path) - assert openapi_request.body == request.data - assert openapi_request.mimetype == request.mimetype - - def test_url_rule(self, request_factory, request): - request = request_factory('GET', '/browse/12/', subdomain='kb') - - openapi_request = FlaskOpenAPIRequest(request) - - path = {'id': 12} - query = ImmutableMultiDict([]) - headers = EnvironHeaders(request.environ) - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - urljoin(request.host_url, '/browse/{id}/') - assert openapi_request.body == request.data - assert openapi_request.mimetype == request.mimetype diff --git a/tests/integration/contrib/flask/test_flask_responses.py b/tests/integration/contrib/flask/test_flask_responses.py deleted file mode 100644 index 24853b1e..00000000 --- a/tests/integration/contrib/flask/test_flask_responses.py +++ /dev/null @@ -1,13 +0,0 @@ -from openapi_core.contrib.flask import FlaskOpenAPIResponse - - -class TestFlaskOpenAPIResponse(object): - - def test_invalid_server(self, response_factory): - response = response_factory('Not Found', status_code=404) - - openapi_response = FlaskOpenAPIResponse(response) - - assert openapi_response.data == response.data - assert openapi_response.status_code == response._status_code - assert openapi_response.mimetype == response.mimetype diff --git a/tests/integration/contrib/flask/test_flask_validation.py b/tests/integration/contrib/flask/test_flask_validation.py deleted file mode 100644 index 672df583..00000000 --- a/tests/integration/contrib/flask/test_flask_validation.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest - -from openapi_core.contrib.flask import ( - FlaskOpenAPIRequest, FlaskOpenAPIResponse, -) -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -class TestFlaskOpenAPIValidation(object): - - @pytest.fixture - def flask_spec(self, factory): - specfile = 'contrib/flask/data/v3.0/flask_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - def test_response_validator_path_pattern(self, - flask_spec, - request_factory, - response_factory): - validator = ResponseValidator(flask_spec) - request = request_factory('GET', '/browse/12/', subdomain='kb') - openapi_request = FlaskOpenAPIRequest(request) - response = response_factory('{"data": "data"}', status_code=200) - openapi_response = FlaskOpenAPIResponse(response) - result = validator.validate(openapi_request, openapi_response) - assert not result.errors - - def test_request_validator_path_pattern(self, flask_spec, request_factory): - validator = RequestValidator(flask_spec) - request = request_factory('GET', '/browse/12/', subdomain='kb') - openapi_request = FlaskOpenAPIRequest(request) - result = validator.validate(openapi_request) - assert not result.errors diff --git a/tests/integration/contrib/flask/test_flask_validator.py b/tests/integration/contrib/flask/test_flask_validator.py new file mode 100644 index 00000000..4e24e848 --- /dev/null +++ b/tests/integration/contrib/flask/test_flask_validator.py @@ -0,0 +1,51 @@ +from json import dumps + +from flask.testing import FlaskClient +from flask.wrappers import Response + +from openapi_core import V30RequestUnmarshaller +from openapi_core.contrib.flask import FlaskOpenAPIRequest + + +class TestFlaskOpenAPIValidation: + def test_request_validator_root_path(self, schema_path, app_factory): + def details_view_func(id): + from flask import request + + openapi_request = FlaskOpenAPIRequest(request) + unmarshaller = V30RequestUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request) + assert not result.errors + + if request.args.get("q") == "string": + return Response( + dumps({"data": "data"}), + headers={"X-Rate-Limit": "12"}, + mimetype="application/json", + status=200, + ) + else: + return Response("Not Found", status=404) + + app = app_factory(root_path="/browse") + app.add_url_rule( + "//", + view_func=details_view_func, + methods=["POST"], + ) + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + client = FlaskClient(app) + result = client.post( + "/12/", + base_url="http://localhost/browse", + query_string=query_string, + json=data, + headers=headers, + ) + + assert result.status_code == 200 + assert result.json == {"data": "data"} diff --git a/tests/integration/contrib/flask/test_flask_views.py b/tests/integration/contrib/flask/test_flask_views.py index 92355e2e..fa00c198 100644 --- a/tests/integration/contrib/flask/test_flask_views.py +++ b/tests/integration/contrib/flask/test_flask_views.py @@ -1,93 +1,95 @@ -from flask import Flask, make_response, jsonify import pytest +from flask import jsonify +from flask import make_response +from openapi_core import Config +from openapi_core import OpenAPI from openapi_core.contrib.flask.views import FlaskOpenAPIView -from openapi_core.shortcuts import create_spec -class TestFlaskOpenAPIView(object): - - view_response = None - - @pytest.fixture - def spec(self, factory): - specfile = 'contrib/flask/data/v3.0/flask_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - @pytest.fixture - def app(self): - app = Flask("__main__") - app.config['DEBUG'] = True - app.config['TESTING'] = True - return app - - @pytest.yield_fixture - def client(self, app): - with app.test_client() as client: - with app.app_context(): - yield client - - @pytest.fixture - def details_view_func(self, spec): - outer = self - - class MyDetailsView(FlaskOpenAPIView): - def get(self, id): - return outer.view_response - - def post(self, id): - return outer.view_response - return MyDetailsView.as_view('browse_details', spec) - +@pytest.fixture(scope="session") +def view_factory(schema_path): + def create( + methods=None, + extra_media_type_deserializers=None, + extra_format_validators=None, + ): + if methods is None: + + def get(view, id): + return make_response("success", 200) + + methods = { + "get": get, + } + MyView = type("MyView", (FlaskOpenAPIView,), methods) + extra_media_type_deserializers = extra_media_type_deserializers or {} + extra_format_validators = extra_format_validators or {} + config = Config( + extra_media_type_deserializers=extra_media_type_deserializers, + extra_format_validators=extra_format_validators, + ) + openapi = OpenAPI(schema_path, config=config) + return MyView.as_view( + "myview", + openapi, + ) + + return create + + +class TestFlaskOpenAPIView: @pytest.fixture - def list_view_func(self, spec): - outer = self + def client(self, client_factory, app): + client = client_factory(app) + with app.app_context(): + yield client - class MyListView(FlaskOpenAPIView): - def get(self): - return outer.view_response - return MyListView.as_view('browse_list', spec) + def test_invalid_content_type(self, client, app, view_factory): + def get(view, id): + view_response = make_response("success", 200) + view_response.headers["X-Rate-Limit"] = "12" + return view_response - @pytest.fixture(autouse=True) - def view(self, app, details_view_func, list_view_func): - app.add_url_rule("/browse//", view_func=details_view_func) - app.add_url_rule("/browse/", view_func=list_view_func) + view_func = view_factory({"get": get}) + app.add_url_rule("/browse//", view_func=view_func) - def test_invalid_content_type(self, client): - self.view_response = make_response('success', 200) - - result = client.get('/browse/12/') + result = client.get("/browse/12/") assert result.status_code == 415 assert result.json == { - 'errors': [ + "errors": [ { - 'class': ( - "" + "class": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. Valid mimetypes: ['application/json']" ), - 'status': 415, - 'title': ( - 'Content for following mimetype not found: text/html' - ) } ] } - def test_server_error(self, client): - result = client.get('/browse/12/', base_url='https://localhost') + def test_server_error(self, client, app, view_factory): + view_func = view_factory() + app.add_url_rule("/browse//", view_func=view_func) + + result = client.get("/browse/12/", base_url="https://localhost") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 400, - 'title': ( - 'Server not found for ' - 'https://localhost/browse/{id}/' + "status": 400, + "title": ( + "Server not found for " + "https://localhost/browse/{id}/" ), } ] @@ -95,20 +97,26 @@ def test_server_error(self, client): assert result.status_code == 400 assert result.json == expected_data - def test_operation_error(self, client): - result = client.post('/browse/12/') + def test_operation_error(self, client, app, view_factory): + def put(view, id): + return make_response("success", 200) + + view_func = view_factory({"put": put}) + app.add_url_rule("/browse//", view_func=view_func) + + result = client.put("/browse/12/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 405, - 'title': ( - 'Operation post not found for ' - 'http://localhost/browse/{id}/' + "status": 405, + "title": ( + "Operation put not found for " + "http://localhost/browse/{id}/" ), } ] @@ -116,20 +124,22 @@ def test_operation_error(self, client): assert result.status_code == 405 assert result.json == expected_data - def test_path_error(self, client): - result = client.get('/browse/') + def test_path_error(self, client, app, view_factory): + view_func = view_factory() + app.add_url_rule("/browse/", view_func=view_func) + + result = client.get("/browse/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 404, - 'title': ( - 'Path not found for ' - 'http://localhost/browse/' + "status": 404, + "title": ( + "Path not found for " "http://localhost/browse/" ), } ] @@ -137,32 +147,66 @@ def test_path_error(self, client): assert result.status_code == 404 assert result.json == expected_data - def test_endpoint_error(self, client): - result = client.get('/browse/invalidparameter/') + def test_endpoint_error(self, client, app, view_factory): + view_func = view_factory() + app.add_url_rule("/browse//", view_func=view_func) + + result = client.get("/browse/invalidparameter/") expected_data = { - 'errors': [ + "errors": [ { - 'class': ( + "class": ( "" ), - 'status': 400, - 'title': ( - "Failed to cast value invalidparameter to type integer" - ) + "status": 400, + "title": ( + "Failed to cast value to integer type: " + "invalidparameter" + ), } ] } assert result.status_code == 400 assert result.json == expected_data - def test_valid(self, client): - self.view_response = jsonify(data='data') + def test_missing_required_header(self, client, app, view_factory): + def get(view, id): + return jsonify(data="data") + + view_func = view_factory({"get": get}) + app.add_url_rule("/browse//", view_func=view_func) + + result = client.get("/browse/12/") + + expected_data = { + "errors": [ + { + "class": ( + "" + ), + "status": 400, + "title": ("Missing required header: X-Rate-Limit"), + } + ] + } + assert result.status_code == 400 + assert result.json == expected_data + + def test_valid(self, client, app, view_factory): + def get(view, id): + resp = jsonify(data="data") + resp.headers["X-Rate-Limit"] = "12" + return resp + + view_func = view_factory({"get": get}) + app.add_url_rule("/browse//", view_func=view_func) - result = client.get('/browse/12/') + result = client.get("/browse/12/") assert result.status_code == 200 assert result.json == { - 'data': 'data', + "data": "data", } diff --git a/tests/integration/contrib/requests/conftest.py b/tests/integration/contrib/requests/conftest.py index fc62bfc8..ffe8d600 100644 --- a/tests/integration/contrib/requests/conftest.py +++ b/tests/integration/contrib/requests/conftest.py @@ -1,38 +1,11 @@ -import pytest -from requests.models import Request, Response -from requests.structures import CaseInsensitiveDict -from six import BytesIO, b -from six.moves.urllib.parse import urljoin, parse_qs -from urllib3.response import HTTPResponse - +import unittest -@pytest.fixture -def request_factory(): - schema = 'http' - server_name = 'localhost' - - def create_request(method, path, subdomain=None, query_string=''): - base_url = '://'.join([schema, server_name]) - url = urljoin(base_url, path) - params = parse_qs(query_string) - headers = { - 'Content-Type': 'application/json', - } - return Request(method, url, params=params, headers=headers) - return create_request +import pytest -@pytest.fixture -def response_factory(): - def create_response( - data, status_code=200, content_type='application/json'): - fp = BytesIO(b(data)) - raw = HTTPResponse(fp, preload_content=False) - resp = Response() - resp.headers = CaseInsensitiveDict({ - 'Content-Type': content_type, - }) - resp.status_code = status_code - resp.raw = raw - return resp - return create_response +@pytest.fixture(autouse=True) +def disable_builtin_socket(scope="session"): + # ResourceWarning from pytest with responses 0.24.0 workaround + # See https://github.com/getsentry/responses/issues/689 + with unittest.mock.patch("socket.socket"): + yield diff --git a/tests/integration/contrib/requests/data/v3.1/requests_factory.yaml b/tests/integration/contrib/requests/data/v3.1/requests_factory.yaml new file mode 100644 index 00000000..64758dd8 --- /dev/null +++ b/tests/integration/contrib/requests/data/v3.1/requests_factory.yaml @@ -0,0 +1,106 @@ +openapi: "3.1.0" +info: + title: Basic OpenAPI specification used with requests integration tests + version: "0.1" +servers: + - url: 'http://localhost' +paths: + '/browse/{id}/': + parameters: + - name: id + in: path + required: true + description: the ID of the resource to retrieve + schema: + type: integer + - name: q + in: query + required: true + description: query key + schema: + type: string + post: + requestBody: + description: request data + required: True + content: + application/json: + schema: + type: object + required: + - param1 + properties: + param1: + type: integer + responses: + 200: + description: Return the resource. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: string + headers: + X-Rate-Limit: + description: Rate limit + schema: + type: integer + required: true + default: + description: Return errors. + content: + application/json: + schema: + type: object + required: + - errors + properties: + errors: + type: array + items: + type: object + properties: + title: + type: string + code: + type: string + message: + type: string +webhooks: + 'resourceAdded': + parameters: + - name: X-Rate-Limit + in: header + required: true + description: Rate limit + schema: + type: integer + post: + requestBody: + description: Added resource data + required: True + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + 200: + description: Callback complete. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: string diff --git a/tests/integration/contrib/requests/test_requests_requests.py b/tests/integration/contrib/requests/test_requests_requests.py deleted file mode 100644 index 45e0258a..00000000 --- a/tests/integration/contrib/requests/test_requests_requests.py +++ /dev/null @@ -1,72 +0,0 @@ -from werkzeug.datastructures import ImmutableMultiDict - -from openapi_core.contrib.requests import RequestsOpenAPIRequest -from openapi_core.validation.request.datatypes import RequestParameters - - -class TestRequestsOpenAPIRequest(object): - - def test_simple(self, request_factory, request): - request = request_factory('GET', '/', subdomain='www') - - openapi_request = RequestsOpenAPIRequest(request) - - path = {} - query = ImmutableMultiDict([]) - headers = request.headers - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == 'http://localhost/' - assert openapi_request.body == request.data - assert openapi_request.mimetype == 'application/json' - - def test_multiple_values(self, request_factory, request): - request = request_factory( - 'GET', '/', subdomain='www', query_string='a=b&a=c') - - openapi_request = RequestsOpenAPIRequest(request) - - path = {} - query = ImmutableMultiDict([ - ('a', 'b'), ('a', 'c'), - ]) - headers = request.headers - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == 'http://localhost/' - assert openapi_request.body == request.data - assert openapi_request.mimetype == 'application/json' - - def test_url_rule(self, request_factory, request): - request = request_factory('GET', '/browse/12/', subdomain='kb') - - openapi_request = RequestsOpenAPIRequest(request) - - # empty when not bound to spec - path = {} - query = ImmutableMultiDict([]) - headers = request.headers - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - 'http://localhost/browse/12/' - assert openapi_request.body == request.data - assert openapi_request.mimetype == 'application/json' diff --git a/tests/integration/contrib/requests/test_requests_responses.py b/tests/integration/contrib/requests/test_requests_responses.py deleted file mode 100644 index 9ad45fde..00000000 --- a/tests/integration/contrib/requests/test_requests_responses.py +++ /dev/null @@ -1,14 +0,0 @@ -from openapi_core.contrib.requests import RequestsOpenAPIResponse - - -class TestRequestsOpenAPIResponse(object): - - def test_invalid_server(self, response_factory): - response = response_factory('Not Found', status_code=404) - - openapi_response = RequestsOpenAPIResponse(response) - - assert openapi_response.data == response.content - assert openapi_response.status_code == response.status_code - mimetype = response.headers.get('Content-Type') - assert openapi_response.mimetype == mimetype diff --git a/tests/integration/contrib/requests/test_requests_validation.py b/tests/integration/contrib/requests/test_requests_validation.py index 6812d93f..b989ee37 100644 --- a/tests/integration/contrib/requests/test_requests_validation.py +++ b/tests/integration/contrib/requests/test_requests_validation.py @@ -1,41 +1,213 @@ +from base64 import b64encode + import pytest import requests import responses -from openapi_core.contrib.requests import ( - RequestsOpenAPIRequest, RequestsOpenAPIResponse, -) -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator +from openapi_core import V30RequestUnmarshaller +from openapi_core import V30ResponseUnmarshaller +from openapi_core import V31RequestUnmarshaller +from openapi_core import V31ResponseUnmarshaller +from openapi_core import V31WebhookRequestUnmarshaller +from openapi_core import V31WebhookResponseUnmarshaller +from openapi_core.contrib.requests import RequestsOpenAPIRequest +from openapi_core.contrib.requests import RequestsOpenAPIResponse +from openapi_core.contrib.requests import RequestsOpenAPIWebhookRequest + + +class TestV31RequestsFactory: + @pytest.fixture + def schema_path(self, schema_path_factory): + specfile = "contrib/requests/data/v3.1/requests_factory.yaml" + return schema_path_factory.from_file(specfile) + @pytest.fixture + def request_unmarshaller(self, schema_path): + return V31RequestUnmarshaller(schema_path) -class TestFlaskOpenAPIValidation(object): + @pytest.fixture + def response_unmarshaller(self, schema_path): + return V31ResponseUnmarshaller(schema_path) @pytest.fixture - def spec(self, factory): - specfile = 'contrib/requests/data/v3.0/requests_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) + def webhook_request_unmarshaller(self, schema_path): + return V31WebhookRequestUnmarshaller(schema_path) + + @pytest.fixture + def webhook_response_unmarshaller(self, schema_path): + return V31WebhookResponseUnmarshaller(schema_path) @responses.activate - def test_response_validator_path_pattern(self, spec): + def test_response_validator_path_pattern(self, response_unmarshaller): responses.add( - responses.GET, 'http://localhost/browse/12/', - json={"data": "data"}, status=200) - validator = ResponseValidator(spec) - request = requests.Request('GET', 'http://localhost/browse/12/') + responses.POST, + "http://localhost/browse/12/?q=string", + json={"data": "data"}, + status=200, + headers={"X-Rate-Limit": "12"}, + ) + request = requests.Request( + "POST", + "http://localhost/browse/12/", + params={"q": "string"}, + headers={"content-type": "application/json"}, + json={"param1": 1}, + ) request_prepared = request.prepare() session = requests.Session() response = session.send(request_prepared) openapi_request = RequestsOpenAPIRequest(request) openapi_response = RequestsOpenAPIResponse(response) - result = validator.validate(openapi_request, openapi_response) + result = response_unmarshaller.unmarshal( + openapi_request, openapi_response + ) + assert not result.errors + + def test_request_validator_path_pattern(self, request_unmarshaller): + request = requests.Request( + "POST", + "http://localhost/browse/12/", + params={"q": "string"}, + headers={"content-type": "application/json"}, + json={"param1": 1}, + ) + openapi_request = RequestsOpenAPIRequest(request) + result = request_unmarshaller.unmarshal(openapi_request) + assert not result.errors + + def test_request_validator_prepared_request(self, request_unmarshaller): + request = requests.Request( + "POST", + "http://localhost/browse/12/", + params={"q": "string"}, + headers={"content-type": "application/json"}, + json={"param1": 1}, + ) + request_prepared = request.prepare() + openapi_request = RequestsOpenAPIRequest(request_prepared) + result = request_unmarshaller.unmarshal(openapi_request) + assert not result.errors + + def test_webhook_request_validator_path( + self, webhook_request_unmarshaller + ): + request = requests.Request( + "POST", + "http://otherhost/callback/", + headers={ + "content-type": "application/json", + "X-Rate-Limit": "12", + }, + json={"id": 1}, + ) + openapi_webhook_request = RequestsOpenAPIWebhookRequest( + request, "resourceAdded" + ) + result = webhook_request_unmarshaller.unmarshal( + openapi_webhook_request + ) assert not result.errors @responses.activate - def test_request_validator_path_pattern(self, spec): - validator = RequestValidator(spec) - request = requests.Request('GET', 'http://localhost/browse/12/') + def test_webhook_response_validator_path( + self, webhook_response_unmarshaller + ): + responses.add( + responses.POST, + "http://otherhost/callback/", + json={"data": "data"}, + status=200, + ) + request = requests.Request( + "POST", + "http://otherhost/callback/", + headers={ + "content-type": "application/json", + "X-Rate-Limit": "12", + }, + json={"id": 1}, + ) + request_prepared = request.prepare() + session = requests.Session() + response = session.send(request_prepared) + openapi_webhook_request = RequestsOpenAPIWebhookRequest( + request, "resourceAdded" + ) + openapi_response = RequestsOpenAPIResponse(response) + result = webhook_response_unmarshaller.unmarshal( + openapi_webhook_request, openapi_response + ) + assert not result.errors + + +class BaseTestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetstore(BaseTestPetstore): + @pytest.fixture + def schema_path(self, schema_path_factory): + specfile = "data/v3.0/petstore.yaml" + return schema_path_factory.from_file(specfile) + + @pytest.fixture + def request_unmarshaller(self, schema_path): + return V30RequestUnmarshaller(schema_path) + + @pytest.fixture + def response_unmarshaller(self, schema_path): + return V30ResponseUnmarshaller(schema_path) + + @responses.activate + def test_response_binary_valid(self, response_unmarshaller, data_gif): + responses.add( + responses.GET, + "http://petstore.swagger.io/v1/pets/1/photo", + body=data_gif, + content_type="image/gif", + status=200, + ) + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + request = requests.Request( + "GET", + "http://petstore.swagger.io/v1/pets/1/photo", + headers=headers, + ) + request_prepared = request.prepare() + session = requests.Session() + response = session.send(request_prepared) openapi_request = RequestsOpenAPIRequest(request) - result = validator.validate(openapi_request) + openapi_response = RequestsOpenAPIResponse(response) + result = response_unmarshaller.unmarshal( + openapi_request, openapi_response + ) + assert not result.errors + assert result.data == data_gif + + @responses.activate + def test_request_binary_valid(self, request_unmarshaller, data_gif): + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": "image/gif", + } + request = requests.Request( + "POST", + "http://petstore.swagger.io/v1/pets/1/photo", + headers=headers, + data=data_gif, + ) + request_prepared = request.prepare() + openapi_request = RequestsOpenAPIRequest(request_prepared) + result = request_unmarshaller.unmarshal(openapi_request) assert not result.errors + assert result.body == data_gif diff --git a/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml b/tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml similarity index 63% rename from tests/integration/contrib/requests/data/v3.0/requests_factory.yaml rename to tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml index abef7eb6..a01168f2 100644 --- a/tests/integration/contrib/requests/data/v3.0/requests_factory.yaml +++ b/tests/integration/contrib/starlette/data/v3.0/starlette_factory.yaml @@ -1,6 +1,6 @@ openapi: "3.0.0" info: - title: Basic OpenAPI specification used with requests integration tests + title: Basic OpenAPI specification used with starlette integration tests version: "0.1" servers: - url: 'http://localhost' @@ -13,7 +13,25 @@ paths: description: the ID of the resource to retrieve schema: type: integer - get: + - name: q + in: query + required: true + description: query key + schema: + type: string + post: + requestBody: + description: request data + required: True + content: + application/json: + schema: + type: object + required: + - param1 + properties: + param1: + type: integer responses: 200: description: Return the resource. @@ -26,6 +44,12 @@ paths: properties: data: type: string + headers: + X-Rate-Limit: + description: Rate limit + schema: + type: integer + required: true default: description: Return errors. content: diff --git a/openapi_core/schema/servers/__init__.py b/tests/integration/contrib/starlette/data/v3.0/starletteproject/__init__.py similarity index 100% rename from openapi_core/schema/servers/__init__.py rename to tests/integration/contrib/starlette/data/v3.0/starletteproject/__init__.py diff --git a/tests/integration/contrib/starlette/data/v3.0/starletteproject/__main__.py b/tests/integration/contrib/starlette/data/v3.0/starletteproject/__main__.py new file mode 100644 index 00000000..79b47802 --- /dev/null +++ b/tests/integration/contrib/starlette/data/v3.0/starletteproject/__main__.py @@ -0,0 +1,32 @@ +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.routing import Route +from starletteproject.openapi import openapi +from starletteproject.pets.endpoints import pet_detail_endpoint +from starletteproject.pets.endpoints import pet_list_endpoint +from starletteproject.pets.endpoints import pet_photo_endpoint + +from openapi_core.contrib.starlette.middlewares import ( + StarletteOpenAPIMiddleware, +) + +middleware = [ + Middleware( + StarletteOpenAPIMiddleware, + openapi=openapi, + ), +] + +routes = [ + Route("/v1/pets", pet_list_endpoint, methods=["GET", "POST"]), + Route("/v1/pets/{petId}", pet_detail_endpoint, methods=["GET", "POST"]), + Route( + "/v1/pets/{petId}/photo", pet_photo_endpoint, methods=["GET", "POST"] + ), +] + +app = Starlette( + debug=True, + middleware=middleware, + routes=routes, +) diff --git a/tests/integration/contrib/starlette/data/v3.0/starletteproject/openapi.py b/tests/integration/contrib/starlette/data/v3.0/starletteproject/openapi.py new file mode 100644 index 00000000..4ca6d9fa --- /dev/null +++ b/tests/integration/contrib/starlette/data/v3.0/starletteproject/openapi.py @@ -0,0 +1,9 @@ +from pathlib import Path + +import yaml + +from openapi_core import OpenAPI + +openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml") +spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader) +openapi = OpenAPI.from_dict(spec_dict) diff --git a/openapi_core/schema/specs/__init__.py b/tests/integration/contrib/starlette/data/v3.0/starletteproject/pets/__init__.py similarity index 100% rename from openapi_core/schema/specs/__init__.py rename to tests/integration/contrib/starlette/data/v3.0/starletteproject/pets/__init__.py diff --git a/tests/integration/contrib/starlette/data/v3.0/starletteproject/pets/endpoints.py b/tests/integration/contrib/starlette/data/v3.0/starletteproject/pets/endpoints.py new file mode 100644 index 00000000..b17b3029 --- /dev/null +++ b/tests/integration/contrib/starlette/data/v3.0/starletteproject/pets/endpoints.py @@ -0,0 +1,100 @@ +from base64 import b64decode + +from starlette.responses import JSONResponse +from starlette.responses import Response +from starlette.responses import StreamingResponse + +OPENID_LOGO = b64decode( + """ +R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d +3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA +AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg +EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD +Fzk0lpcjIQA7 +""" +) + + +async def pet_list_endpoint(request): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + if request.method == "GET": + assert request.scope["openapi"].parameters.query == { + "page": 1, + "limit": 12, + "search": "", + } + data = [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ] + response_dict = { + "data": data, + } + headers = { + "X-Rate-Limit": "12", + } + return JSONResponse(response_dict, headers=headers) + elif request.method == "POST": + assert request.scope["openapi"].parameters.cookie == { + "user": 1, + } + assert request.scope["openapi"].parameters.header == { + "api-key": "12345", + } + assert request.scope["openapi"].body.__class__.__name__ == "PetCreate" + assert request.scope["openapi"].body.name in ["Cat", "Bird"] + if request.scope["openapi"].body.name == "Cat": + assert ( + request.scope["openapi"].body.ears.__class__.__name__ == "Ears" + ) + assert request.scope["openapi"].body.ears.healthy is True + if request.scope["openapi"].body.name == "Bird": + assert ( + request.scope["openapi"].body.wings.__class__.__name__ + == "Wings" + ) + assert request.scope["openapi"].body.wings.healthy is True + + headers = { + "X-Rate-Limit": "12", + } + return Response(status_code=201, headers=headers) + + +async def pet_detail_endpoint(request): + assert request.scope["openapi"] + assert not request.scope["openapi"].errors + if request.method == "GET": + assert request.scope["openapi"].parameters.path == { + "petId": 12, + } + data = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + response_dict = { + "data": data, + } + headers = { + "X-Rate-Limit": "12", + } + return JSONResponse(response_dict, headers=headers) + + +async def pet_photo_endpoint(request): + if request.method == "GET": + contents = iter([OPENID_LOGO]) + return StreamingResponse(contents, media_type="image/gif") + elif request.method == "POST": + body = await request.body() + assert body == OPENID_LOGO + return Response(status_code=201) diff --git a/tests/integration/contrib/starlette/test_starlette_project.py b/tests/integration/contrib/starlette/test_starlette_project.py new file mode 100644 index 00000000..d1e8ed54 --- /dev/null +++ b/tests/integration/contrib/starlette/test_starlette_project.py @@ -0,0 +1,383 @@ +import os +import sys +from base64 import b64encode + +import pytest +from starlette.testclient import TestClient + + +@pytest.fixture(autouse=True, scope="module") +def project_setup(): + directory = os.path.abspath(os.path.dirname(__file__)) + project_dir = os.path.join(directory, "data/v3.0") + sys.path.insert(0, project_dir) + yield + sys.path.remove(project_dir) + + +@pytest.fixture +def app(): + from starletteproject.__main__ import app + + return app + + +@pytest.fixture +def client(app): + return TestClient(app, base_url="http://petstore.swagger.io") + + +class BaseTestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + +class TestPetListEndpoint(BaseTestPetstore): + def test_get_no_required_param(self, client): + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get("/v1/pets", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required query parameter: limit", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_valid(self, client): + data_json = { + "limit": 12, + } + headers = { + "Authorization": "Basic testuser", + } + + with pytest.warns(DeprecationWarning): + response = client.get( + "/v1/pets", + params=data_json, + headers=headers, + ) + + expected_data = { + "data": [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + ], + } + assert response.status_code == 200 + assert response.json() == expected_data + + def test_post_server_invalid(self, client): + response = client.post("/v1/pets") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " + "http://petstore.swagger.io/v1/pets" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_required_header_param_missing(self, client): + client.cookies.set("user", "1") + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required header parameter: api-key", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_post_media_type_invalid(self, client): + client.cookies.set("user", "1") + content = "data" + content_type = "text/html" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + content=content, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 415, + "title": ( + "Content for the following mimetype not found: " + "text/html. " + "Valid mimetypes: ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']" + ), + } + ] + } + assert response.status_code == 415 + assert response.json() == expected_data + + def test_post_required_cookie_param_missing(self, client): + data_json = { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": "Missing required cookie parameter: user", + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + @pytest.mark.parametrize( + "data_json", + [ + { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + { + "id": 12, + "name": "Bird", + "wings": { + "healthy": True, + }, + }, + ], + ) + def test_post_valid(self, client, data_json): + client.cookies.set("user", "1") + content_type = "application/json" + headers = { + "Authorization": "Basic testuser", + "Content-Type": content_type, + "Api-Key": self.api_key_encoded, + } + response = client.post( + "https://staging.gigantic-server.com/v1/pets", + json=data_json, + headers=headers, + ) + + assert response.status_code == 201 + assert not response.content + + +class TestPetDetailEndpoint(BaseTestPetstore): + def test_get_server_invalid(self, client): + response = client.get("http://testserver/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 400, + "title": ( + "Server not found for " "http://testserver/v1/pets/12" + ), + } + ] + } + assert response.status_code == 400 + assert response.json() == expected_data + + def test_get_unauthorized(self, client): + response = client.get("/v1/pets/12") + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 403, + "title": ( + "Security not found. Schemes not valid for any " + "requirement: [['petstore_auth']]" + ), + } + ] + } + assert response.status_code == 403 + assert response.json() == expected_data + + def test_delete_method_invalid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.delete("/v1/pets/12", headers=headers) + + expected_data = { + "errors": [ + { + "type": ( + "" + ), + "status": 405, + "title": ( + "Operation delete not found for " + "http://petstore.swagger.io/v1/pets/12" + ), + } + ] + } + assert response.status_code == 405 + assert response.json() == expected_data + + def test_get_valid(self, client): + headers = { + "Authorization": "Basic testuser", + } + response = client.get("/v1/pets/12", headers=headers) + + expected_data = { + "data": { + "id": 12, + "name": "Cat", + "ears": { + "healthy": True, + }, + }, + } + assert response.status_code == 200 + assert response.json() == expected_data + + +class TestPetPhotoEndpoint(BaseTestPetstore): + def test_get_valid(self, client, data_gif): + client.cookies.set("user", "1") + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + } + + response = client.get( + "/v1/pets/1/photo", + headers=headers, + ) + + assert response.content == data_gif + assert response.status_code == 200 + + def test_post_valid(self, client, data_gif): + client.cookies.set("user", "1") + content_type = "image/gif" + headers = { + "Authorization": "Basic testuser", + "Api-Key": self.api_key_encoded, + "Content-Type": content_type, + } + + response = client.post( + "/v1/pets/1/photo", + headers=headers, + content=data_gif, + ) + + assert not response.text + assert response.status_code == 201 diff --git a/tests/integration/contrib/starlette/test_starlette_validation.py b/tests/integration/contrib/starlette/test_starlette_validation.py new file mode 100644 index 00000000..6bebcfbb --- /dev/null +++ b/tests/integration/contrib/starlette/test_starlette_validation.py @@ -0,0 +1,121 @@ +from json import dumps + +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from openapi_core import unmarshal_request +from openapi_core import unmarshal_response +from openapi_core.contrib.starlette import StarletteOpenAPIRequest +from openapi_core.contrib.starlette import StarletteOpenAPIResponse + + +class TestV30StarletteFactory: + @pytest.fixture + def schema_path(self, schema_path_factory): + specfile = "contrib/starlette/data/v3.0/starlette_factory.yaml" + return schema_path_factory.from_file(specfile) + + @pytest.fixture + def app(self): + async def test_route(scope, receive, send): + request = Request(scope, receive) + if request.args.get("q") == "string": + response = JSONResponse( + dumps({"data": "data"}), + headers={"X-Rate-Limit": "12"}, + mimetype="application/json", + status=200, + ) + else: + response = PlainTextResponse("Not Found", status=404) + await response(scope, receive, send) + + return Starlette( + routes=[ + Route("/browse/12/", test_route), + ], + ) + + @pytest.fixture + def client(self, app): + return TestClient(app, base_url="http://localhost") + + def test_request_validator_path_pattern(self, client, schema_path): + response_data = {"data": "data"} + + async def test_route(request): + body = await request.body() + openapi_request = StarletteOpenAPIRequest(request, body) + result = unmarshal_request(openapi_request, schema_path) + assert not result.errors + return JSONResponse( + response_data, + headers={"X-Rate-Limit": "12"}, + media_type="application/json", + status_code=200, + ) + + app = Starlette( + routes=[ + Route("/browse/12/", test_route, methods=["POST"]), + ], + ) + client = TestClient(app, base_url="http://localhost") + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + response = client.post( + "/browse/12/", + params=query_string, + json=data, + headers=headers, + ) + + assert response.status_code == 200 + assert response.json() == response_data + + def test_response_validator_path_pattern(self, client, schema_path): + response_data = {"data": "data"} + + def test_route(request): + response = JSONResponse( + response_data, + headers={"X-Rate-Limit": "12"}, + media_type="application/json", + status_code=200, + ) + openapi_request = StarletteOpenAPIRequest(request) + openapi_response = StarletteOpenAPIResponse(response) + result = unmarshal_response( + openapi_request, openapi_response, schema_path + ) + assert not result.errors + return response + + app = Starlette( + routes=[ + Route("/browse/12/", test_route, methods=["POST"]), + ], + ) + client = TestClient(app, base_url="http://localhost") + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + response = client.post( + "/browse/12/", + params=query_string, + json=data, + headers=headers, + ) + + assert response.status_code == 200 + assert response.json() == response_data diff --git a/tests/integration/contrib/test_django.py b/tests/integration/contrib/test_django.py deleted file mode 100644 index 7d608798..00000000 --- a/tests/integration/contrib/test_django.py +++ /dev/null @@ -1,192 +0,0 @@ -import sys - -import pytest -from six import b - -from openapi_core.contrib.django import ( - DjangoOpenAPIRequest, DjangoOpenAPIResponse, -) -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.datatypes import RequestParameters -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -@pytest.mark.skipif(sys.version_info < (3, 0), reason="requires python3") -class BaseTestDjango(object): - - @pytest.fixture(autouse=True, scope='module') - def django_settings(self): - import django - from django.conf import settings - from django.contrib import admin - from django.urls import path - - if settings.configured: - return - - settings.configure( - ALLOWED_HOSTS=[ - 'testserver', - ], - INSTALLED_APPS=[ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.messages', - 'django.contrib.sessions', - ], - MIDDLEWARE=[ - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - ] - ) - django.setup() - settings.ROOT_URLCONF = ( - path('admin/', admin.site.urls), - ) - - @pytest.fixture - def request_factory(self): - from django.test.client import RequestFactory - return RequestFactory() - - @pytest.fixture - def response_factory(self): - from django.http import HttpResponse - - def create(content=b(''), status_code=None): - return HttpResponse(content, status=status_code) - - return create - - -class TestDjangoOpenAPIRequest(BaseTestDjango): - - def test_no_resolver(self, request_factory): - request = request_factory.get('/admin/') - - openapi_request = DjangoOpenAPIRequest(request) - - path = {} - query = {} - headers = { - 'Cookie': '', - } - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - request._current_scheme_host + request.path - assert openapi_request.body == request.body - assert openapi_request.mimetype == request.content_type - - def test_simple(self, request_factory): - from django.urls import resolve - request = request_factory.get('/admin/') - request.resolver_match = resolve('/admin/') - - openapi_request = DjangoOpenAPIRequest(request) - - path = {} - query = {} - headers = { - 'Cookie': '', - } - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - request._current_scheme_host + request.path - assert openapi_request.body == request.body - assert openapi_request.mimetype == request.content_type - - def test_url_rule(self, request_factory): - from django.urls import resolve - request = request_factory.get('/admin/auth/group/1/') - request.resolver_match = resolve('/admin/auth/group/1/') - - openapi_request = DjangoOpenAPIRequest(request) - - path = { - 'object_id': '1', - } - query = {} - headers = { - 'Cookie': '', - } - cookies = {} - assert openapi_request.parameters == RequestParameters( - path=path, - query=query, - header=headers, - cookie=cookies, - ) - assert openapi_request.method == request.method.lower() - assert openapi_request.full_url_pattern == \ - request._current_scheme_host + "/admin/auth/group/{object_id}/" - assert openapi_request.body == request.body - assert openapi_request.mimetype == request.content_type - - -class TestDjangoOpenAPIResponse(BaseTestDjango): - - def test_stream_response(self, response_factory): - response = response_factory() - response.writelines(['foo\n', 'bar\n', 'baz\n']) - - openapi_response = DjangoOpenAPIResponse(response) - - assert openapi_response.data == b('foo\nbar\nbaz\n') - assert openapi_response.status_code == response.status_code - assert openapi_response.mimetype == response["Content-Type"] - - def test_redirect_response(self, response_factory): - response = response_factory('/redirected/', status_code=302) - - openapi_response = DjangoOpenAPIResponse(response) - - assert openapi_response.data == response.content - assert openapi_response.status_code == response.status_code - assert openapi_response.mimetype == response["Content-Type"] - - -class TestDjangoOpenAPIValidation(BaseTestDjango): - - @pytest.fixture - def django_spec(self, factory): - specfile = 'data/v3.0/django_factory.yaml' - return create_spec(factory.spec_from_file(specfile)) - - def test_response_validator_path_pattern( - self, django_spec, request_factory, response_factory): - from django.urls import resolve - validator = ResponseValidator(django_spec) - request = request_factory.get('/admin/auth/group/1/') - request.resolver_match = resolve('/admin/auth/group/1/') - openapi_request = DjangoOpenAPIRequest(request) - response = response_factory(b('Some item')) - openapi_response = DjangoOpenAPIResponse(response) - result = validator.validate(openapi_request, openapi_response) - assert not result.errors - - def test_request_validator_path_pattern( - self, django_spec, request_factory): - from django.urls import resolve - validator = RequestValidator(django_spec) - request = request_factory.get('/admin/auth/group/1/') - request.resolver_match = resolve('/admin/auth/group/1/') - openapi_request = DjangoOpenAPIRequest(request) - result = validator.validate(openapi_request) - assert not result.errors diff --git a/tests/integration/contrib/werkzeug/test_werkzeug_validation.py b/tests/integration/contrib/werkzeug/test_werkzeug_validation.py new file mode 100644 index 00000000..a2641ca8 --- /dev/null +++ b/tests/integration/contrib/werkzeug/test_werkzeug_validation.py @@ -0,0 +1,96 @@ +from json import dumps + +import pytest +import responses +from werkzeug.test import Client +from werkzeug.wrappers import Request +from werkzeug.wrappers import Response + +from openapi_core import V30RequestUnmarshaller +from openapi_core import V30ResponseUnmarshaller +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIRequest +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIResponse + + +class TestWerkzeugOpenAPIValidation: + @pytest.fixture + def schema_path(self, schema_path_factory): + specfile = "contrib/requests/data/v3.1/requests_factory.yaml" + return schema_path_factory.from_file(specfile) + + @pytest.fixture + def app(self): + def test_app(environ, start_response): + req = Request(environ, populate_request=False) + if req.args.get("q") == "string": + response = Response( + dumps({"data": "data"}), + headers={"X-Rate-Limit": "12"}, + mimetype="application/json", + status=200, + ) + else: + response = Response("Not Found", status=404) + return response(environ, start_response) + + return test_app + + @pytest.fixture + def client(self, app): + return Client(app) + + def test_request_validator_root_path(self, client, schema_path): + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + response = client.post( + "/12/", + base_url="http://localhost/browse", + query_string=query_string, + json=data, + headers=headers, + ) + openapi_request = WerkzeugOpenAPIRequest(response.request) + unmarshaller = V30RequestUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request) + assert not result.errors + + def test_request_validator_path_pattern(self, client, schema_path): + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + response = client.post( + "/browse/12/", + base_url="http://localhost", + query_string=query_string, + json=data, + headers=headers, + ) + openapi_request = WerkzeugOpenAPIRequest(response.request) + unmarshaller = V30RequestUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request) + assert not result.errors + + @responses.activate + def test_response_validator_path_pattern(self, client, schema_path): + query_string = { + "q": "string", + } + headers = {"content-type": "application/json"} + data = {"param1": 1} + response = client.post( + "/browse/12/", + base_url="http://localhost", + query_string=query_string, + json=data, + headers=headers, + ) + openapi_request = WerkzeugOpenAPIRequest(response.request) + openapi_response = WerkzeugOpenAPIResponse(response) + unmarshaller = V30ResponseUnmarshaller(schema_path) + result = unmarshaller.unmarshal(openapi_request, openapi_response) + assert not result.errors diff --git a/tests/integration/data/v3.0/parent-reference/openapi.yaml b/tests/integration/data/v3.0/parent-reference/openapi.yaml new file mode 100644 index 00000000..51150416 --- /dev/null +++ b/tests/integration/data/v3.0/parent-reference/openapi.yaml @@ -0,0 +1,7 @@ +openapi: "3.0.0" +info: + title: sample + version: "0.1" +paths: + /books: + $ref: "./paths/books.yaml" \ No newline at end of file diff --git a/tests/integration/data/v3.0/parent-reference/paths/books.yaml b/tests/integration/data/v3.0/parent-reference/paths/books.yaml new file mode 100644 index 00000000..d625f4f5 --- /dev/null +++ b/tests/integration/data/v3.0/parent-reference/paths/books.yaml @@ -0,0 +1,10 @@ +get: + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "../schemas/book.yaml#/Book" \ No newline at end of file diff --git a/tests/integration/data/v3.0/parent-reference/schemas/book.yaml b/tests/integration/data/v3.0/parent-reference/schemas/book.yaml new file mode 100644 index 00000000..1bf35402 --- /dev/null +++ b/tests/integration/data/v3.0/parent-reference/schemas/book.yaml @@ -0,0 +1,9 @@ +Book: + type: object + properties: + id: + $ref: "#/BookId" + title: + type: string +BookId: + type: string \ No newline at end of file diff --git a/tests/integration/data/v3.0/petstore.yaml b/tests/integration/data/v3.0/petstore.yaml index 1785a5b7..735fd96c 100644 --- a/tests/integration/data/v3.0/petstore.yaml +++ b/tests/integration/data/v3.0/petstore.yaml @@ -38,8 +38,10 @@ paths: default: 1 - name: limit in: query + style: form description: How many items to return at one time (max 100) required: true + deprecated: true schema: type: integer format: int32 @@ -59,6 +61,13 @@ paths: items: type: integer format: int32 + - name: order + in: query + schema: + oneOf: + - type: string + - type: integer + format: int32 - name: tags in: query description: Filter pets with tags @@ -67,9 +76,34 @@ paths: items: $ref: "#/components/schemas/Tag" explode: false + - name: coordinates + in: query + content: + application/json: + schema: + $ref: "#/components/schemas/Coordinates" + - name: color + in: query + description: RGB color + style: deepObject + required: false + explode: true + schema: + type: object + properties: + R: + type: integer + G: + type: integer + B: + type: integer responses: '200': $ref: "#/components/responses/PetsResponse" + '400': + $ref: "#/components/responses/ErrorResponse" + '404': + $ref: "#/components/responses/HtmlResponse" post: summary: Create a pet description: Creates new pet entry @@ -85,7 +119,7 @@ paths: tags: - pets parameters: - - name: api_key + - name: api-key in: header schema: type: string @@ -97,6 +131,13 @@ paths: type: integer format: int32 required: true + - name: userdata + in: cookie + content: + application/json: + schema: + $ref: '#/components/schemas/Userdata' + required: false requestBody: required: true content: @@ -106,6 +147,13 @@ paths: example: name: "Pet" wings: [] + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PetCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PetWithPhotoCreate' + text/plain: {} responses: '201': description: Null response @@ -146,6 +194,56 @@ paths: format: binary default: $ref: "#/components/responses/ErrorResponse" + /pets/{petId}/photo: + get: + summary: Photo for a specific pet + operationId: showPetPhotoById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: integer + format: int64 + responses: + '200': + description: Expected response to a valid request + content: + image/*: + schema: + type: string + format: binary + default: + $ref: "#/components/responses/ErrorResponse" + post: + summary: Create a pet photo + description: Creates new pet photo entry + operationId: createPetPhotoById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: integer + format: int64 + requestBody: + required: true + content: + image/*: + schema: + type: string + format: binary + responses: + '201': + description: Null response + default: + $ref: "#/components/responses/ErrorResponse" /tags: get: summary: List all tags @@ -175,13 +273,63 @@ paths: application/json: schema: $ref: '#/components/schemas/TagCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TagCreate' + responses: + '200': + description: Null response + default: + $ref: "#/components/responses/ErrorResponse" + delete: + summary: Delete tags + operationId: deleteTag + tags: + - tags + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/TagDelete' responses: '200': description: Null response + headers: + x-delete-confirm: + description: Confirmation automation + deprecated: true + schema: + type: boolean + required: true + x-delete-date: + description: Confirmation automation date + schema: + type: string + format: date default: $ref: "#/components/responses/ErrorResponse" components: schemas: + Coordinates: + x-model: Coordinates + type: object + required: + - lat + - lon + properties: + lat: + type: number + lon: + type: number + Userdata: + x-model: Userdata + type: object + required: + - name + properties: + name: + type: string Utctime: oneOf: - type: string @@ -230,6 +378,16 @@ components: oneOf: - $ref: "#/components/schemas/Cat" - $ref: "#/components/schemas/Bird" + PetWithPhotoCreate: + type: object + x-model: PetWithPhotoCreate + allOf: + - $ref: "#/components/schemas/PetCreatePartOne" + - $ref: "#/components/schemas/PetCreatePartTwo" + - $ref: "#/components/schemas/PetCreatePartPhoto" + oneOf: + - $ref: "#/components/schemas/Cat" + - $ref: "#/components/schemas/Bird" PetCreatePartOne: type: object x-model: PetCreatePartOne @@ -250,6 +408,15 @@ components: $ref: "#/components/schemas/Position" healthy: type: boolean + PetCreatePartPhoto: + type: object + x-model: PetCreatePartPhoto + properties: + photo: + $ref: "#/components/schemas/PetPhoto" + PetPhoto: + type: string + format: binary Bird: type: object x-model: Bird @@ -313,6 +480,18 @@ components: name: type: string additionalProperties: false + TagDelete: + type: object + x-model: TagDelete + required: + - ids + properties: + ids: + type: array + items: + type: integer + format: int64 + additionalProperties: false TagList: type: array items: @@ -320,14 +499,39 @@ components: Error: type: object required: - - code - message properties: code: type: integer format: int32 + default: 400 message: type: string + StandardError: + type: object + x-model: StandardError + required: + - title + - status + - type + properties: + title: + type: string + status: + type: integer + format: int32 + default: 400 + type: + type: string + StandardErrors: + type: object + required: + - errors + properties: + errors: + type: array + items: + $ref: "#/components/schemas/StandardError" ExtendedError: type: object x-model: ExtendedError @@ -355,10 +559,21 @@ components: content: application/json: schema: - $ref: "#/components/schemas/ExtendedError" + x-model: Error + oneOf: + - $ref: "#/components/schemas/StandardErrors" + - $ref: "#/components/schemas/ExtendedError" + HtmlResponse: + description: HTML page + content: + text/html: {} PetsResponse: description: An paged array of pets headers: + content-type: + description: Content type + schema: + type: string x-next: description: A link to the next page of responses schema: diff --git a/tests/integration/data/v3.0/read_only_write_only.yaml b/tests/integration/data/v3.0/read_only_write_only.yaml index be5a06a4..1f403df7 100644 --- a/tests/integration/data/v3.0/read_only_write_only.yaml +++ b/tests/integration/data/v3.0/read_only_write_only.yaml @@ -23,6 +23,7 @@ paths: components: schemas: User: + x-model: User type: object required: - id diff --git a/tests/integration/data/v3.1/empty.yaml b/tests/integration/data/v3.1/empty.yaml new file mode 100644 index 00000000..7bd07431 --- /dev/null +++ b/tests/integration/data/v3.1/empty.yaml @@ -0,0 +1 @@ +openapi: "3.1.0" diff --git a/tests/integration/data/v3.1/links.yaml b/tests/integration/data/v3.1/links.yaml new file mode 100644 index 00000000..a35733fa --- /dev/null +++ b/tests/integration/data/v3.1/links.yaml @@ -0,0 +1,48 @@ +openapi: "3.1.0" +info: + title: Minimal valid OpenAPI specification + version: "0.1" +paths: + /linked/noParam: + get: + operationId: noParOp + responses: + default: + description: the linked result + /linked/withParam: + get: + operationId: paramOp + parameters: + - name: opParam + in: query + description: test + schema: + type: string + responses: + default: + description: the linked result + /status: + get: + responses: + default: + description: Return something + links: + noParamLink: + operationId: noParOp + /status/{resourceId}: + get: + parameters: + - name: resourceId + in: path + required: true + schema: + type: string + responses: + default: + description: Return something else + links: + paramLink: + operationId: paramOp + parameters: + opParam: $request.path.resourceId + requestBody: test \ No newline at end of file diff --git a/tests/integration/data/v3.1/minimal.yaml b/tests/integration/data/v3.1/minimal.yaml new file mode 100644 index 00000000..94fb971e --- /dev/null +++ b/tests/integration/data/v3.1/minimal.yaml @@ -0,0 +1,10 @@ +openapi: "3.1.0" +info: + title: Minimal valid OpenAPI specification + version: "0.1" +paths: + /status: + get: + responses: + default: + description: Return the API status. \ No newline at end of file diff --git a/tests/integration/data/v3.1/minimal_with_servers.yaml b/tests/integration/data/v3.1/minimal_with_servers.yaml new file mode 100644 index 00000000..d437c20f --- /dev/null +++ b/tests/integration/data/v3.1/minimal_with_servers.yaml @@ -0,0 +1,12 @@ +openapi: "3.1.0" +info: + title: Minimal valid OpenAPI specification with explicit 'servers' array + version: "0.1" +servers: + - url: / +paths: + /status: + get: + responses: + default: + description: Return the API status. \ No newline at end of file diff --git a/tests/integration/data/v3.1/path_param.yaml b/tests/integration/data/v3.1/path_param.yaml new file mode 100644 index 00000000..72c9b676 --- /dev/null +++ b/tests/integration/data/v3.1/path_param.yaml @@ -0,0 +1,17 @@ +openapi: "3.1.0" +info: + title: Minimal OpenAPI specification with path parameters + version: "0.1" +paths: + /resource/{resId}: + parameters: + - name: resId + in: path + required: true + description: the ID of the resource to retrieve + schema: + type: string + get: + responses: + default: + description: Return the resource. \ No newline at end of file diff --git a/tests/integration/data/v3.1/security_override.yaml b/tests/integration/data/v3.1/security_override.yaml new file mode 100644 index 00000000..9d6bec96 --- /dev/null +++ b/tests/integration/data/v3.1/security_override.yaml @@ -0,0 +1,41 @@ +openapi: "3.1.0" +info: + title: Minimal OpenAPI specification with security override + version: "0.1" +security: + - api_key: [] +paths: + /resource/{resId}: + parameters: + - name: resId + in: path + required: true + description: the ID of the resource to retrieve + schema: + type: string + get: + responses: + default: + description: Default security. + post: + security: + - petstore_auth: + - write:pets + - read:pets + responses: + default: + description: Override security. + put: + security: [] + responses: + default: + description: Remove security. +components: + securitySchemes: + api_key: + type: apiKey + name: api_key + in: query + petstore_auth: + type: http + scheme: basic \ No newline at end of file diff --git a/tests/integration/data/v3.1/webhook-example.yaml b/tests/integration/data/v3.1/webhook-example.yaml new file mode 100644 index 00000000..44fc73aa --- /dev/null +++ b/tests/integration/data/v3.1/webhook-example.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Webhook Example + version: 1.0.0 +# Since OAS 3.1.0 the paths element isn't necessary. Now a valid OpenAPI Document can describe only paths, webhooks, or even only reusable components +webhooks: + # Each webhook needs a name + newPet: + # This is a Path Item Object, the only difference is that the request is initiated by the API provider + post: + requestBody: + description: Information about a new pet in the system + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Return a 200 status to indicate that the data was received successfully + +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string diff --git a/tests/integration/schema/test_empty.py b/tests/integration/schema/test_empty.py deleted file mode 100644 index ea5b276e..00000000 --- a/tests/integration/schema/test_empty.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest -from jsonschema.exceptions import ValidationError - -from openapi_core.shortcuts import create_spec - - -class TestEmpty(object): - - @pytest.fixture - def spec_dict(self, factory): - return factory.spec_from_file("data/v3.0/empty.yaml") - - @pytest.fixture - def spec(self, spec_dict): - return create_spec(spec_dict) - - def test_raises_on_invalid(self, spec_dict): - with pytest.raises(ValidationError): - create_spec(spec_dict) diff --git a/tests/integration/schema/test_link_spec.py b/tests/integration/schema/test_link_spec.py index b399b41a..2abb5b75 100644 --- a/tests/integration/schema/test_link_spec.py +++ b/tests/integration/schema/test_link_spec.py @@ -1,36 +1,48 @@ -from openapi_core.shortcuts import create_spec - - -class TestLinkSpec(object): - - def test_no_param(self, factory): - spec_dict = factory.spec_from_file("data/v3.0/links.yaml") - spec = create_spec(spec_dict) - resp = spec['/status']['get'].get_response() - - assert len(resp.links) == 1 - - link = resp.links['noParamLink'] - - assert link.operationId == 'noParOp' - assert link.server is None - assert link.request_body is None - assert len(link.parameters) == 0 - - def test_param(self, factory): - spec_dict = factory.spec_from_file("data/v3.0/links.yaml") - spec = create_spec(spec_dict) - resp = spec['/status/{resourceId}']['get'].get_response() - - assert len(resp.links) == 1 - - link = resp.links['paramLink'] - - assert link.operationId == 'paramOp' - assert link.server is None - assert link.request_body == 'test' - assert len(link.parameters) == 1 - - param = link.parameters['opParam'] - - assert param == '$request.path.resourceId' +import pytest + + +class TestLinkSpec: + @pytest.mark.parametrize( + "spec_file", + [ + "data/v3.0/links.yaml", + "data/v3.1/links.yaml", + ], + ) + def test_no_param(self, spec_file, schema_path_factory): + schema_path = schema_path_factory.from_file(spec_file) + resp = schema_path / "paths#/status#get#responses#default" + + links = resp / "links" + assert len(links) == 1 + + link = links / "noParamLink" + assert link["operationId"] == "noParOp" + assert "server" not in link + assert "requestBody" not in link + assert "parameters" not in link + + @pytest.mark.parametrize( + "spec_file", + [ + "data/v3.0/links.yaml", + "data/v3.1/links.yaml", + ], + ) + def test_param(self, spec_file, schema_path_factory): + schema_path = schema_path_factory.from_file(spec_file) + resp = schema_path / "paths#/status/{resourceId}#get#responses#default" + + links = resp / "links" + assert len(links) == 1 + + link = links / "paramLink" + assert link["operationId"] == "paramOp" + assert "server" not in link + assert link["requestBody"] == "test" + + parameters = link["parameters"] + assert len(parameters) == 1 + + param = parameters["opParam"] + assert param == "$request.path.resourceId" diff --git a/tests/integration/schema/test_path_params.py b/tests/integration/schema/test_path_params.py index e2c6d544..20d3e6d9 100644 --- a/tests/integration/schema/test_path_params.py +++ b/tests/integration/schema/test_path_params.py @@ -1,23 +1,23 @@ import pytest -from openapi_core.schema.parameters.enums import ParameterLocation -from openapi_core.shortcuts import create_spec +class TestMinimal: + @pytest.mark.parametrize( + "spec_file", + [ + "data/v3.0/path_param.yaml", + "data/v3.1/path_param.yaml", + ], + ) + def test_param_present(self, spec_file, schema_path_factory): + schema_path = schema_path_factory.from_file(spec_file) -class TestMinimal(object): + path = schema_path / "paths#/resource/{resId}" - spec_paths = [ - "data/v3.0/path_param.yaml" - ] + parameters = path / "parameters" + assert len(parameters) == 1 - @pytest.mark.parametrize("spec_path", spec_paths) - def test_param_present(self, factory, spec_path): - spec_dict = factory.spec_from_file(spec_path) - spec = create_spec(spec_dict) - - path = spec['/resource/{resId}'] - - assert len(path.parameters) == 1 - param = path.parameters['resId'] - assert param.required - assert param.location == ParameterLocation.PATH + param = parameters[0] + assert param["name"] == "resId" + assert param["required"] + assert param["in"] == "path" diff --git a/tests/integration/schema/test_spec.py b/tests/integration/schema/test_spec.py index 0537306a..d8191f3e 100644 --- a/tests/integration/schema/test_spec.py +++ b/tests/integration/schema/test_spec.py @@ -1,277 +1,334 @@ -import pytest from base64 import b64encode -from six import iteritems, text_type -from openapi_core.schema.media_types.models import MediaType -from openapi_core.schema.operations.models import Operation -from openapi_core.schema.parameters.models import Parameter -from openapi_core.schema.paths.models import Path -from openapi_core.schema.request_bodies.models import RequestBody -from openapi_core.schema.responses.models import Response -from openapi_core.schema.schemas.models import Schema -from openapi_core.schema.security_requirements.models import ( - SecurityRequirement, -) -from openapi_core.schema.servers.models import Server, ServerVariable -from openapi_core.shortcuts import create_spec -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator +import pytest +from jsonschema_path import SchemaPath +from openapi_core.schema.servers import get_server_url +from openapi_core.schema.specs import get_spec_url -class TestPetstore(object): - api_key = '12345' +class TestPetstore: + api_key = "12345" @property def api_key_encoded(self): - api_key_bytes = self.api_key.encode('utf8') + api_key_bytes = self.api_key.encode("utf8") api_key_bytes_enc = b64encode(api_key_bytes) - return text_type(api_key_bytes_enc, 'utf8') + return str(api_key_bytes_enc, "utf8") @pytest.fixture - def spec_uri(self): + def base_uri(self): return "file://tests/integration/data/v3.0/petstore.yaml" @pytest.fixture - def spec_dict(self, factory): - return factory.spec_from_file("data/v3.0/petstore.yaml") + def spec_dict(self, content_factory): + content, _ = content_factory.from_file("data/v3.0/petstore.yaml") + return content @pytest.fixture - def spec(self, spec_dict, spec_uri): - return create_spec(spec_dict, spec_uri) - - @pytest.fixture - def request_validator(self, spec): - return RequestValidator(spec) - - @pytest.fixture - def response_validator(self, spec): - return ResponseValidator(spec) - - def test_spec(self, spec, spec_dict): - url = 'http://petstore.swagger.io/v1' - - info_spec = spec_dict['info'] - assert spec.info.title == info_spec['title'] - assert spec.info.description == info_spec['description'] - assert spec.info.terms_of_service == info_spec['termsOfService'] - assert spec.info.version == info_spec['version'] - - contact_spec = info_spec['contact'] - assert spec.info.contact.name == contact_spec['name'] - assert spec.info.contact.url == contact_spec['url'] - assert spec.info.contact.email == contact_spec['email'] - - license_spec = info_spec['license'] - assert spec.info.license.name == license_spec['name'] - assert spec.info.license.url == license_spec['url'] - - security_spec = spec_dict.get('security', []) - for idx, security_req in enumerate(spec.security): - assert type(security_req) == SecurityRequirement - - security_req_spec = security_spec[idx] - for scheme_name in security_req: - security_req[scheme_name] == security_req_spec[scheme_name] - - assert spec.get_server_url() == url - - for idx, server in enumerate(spec.servers): - assert type(server) == Server - - server_spec = spec_dict['servers'][idx] - assert server.url == server_spec['url'] - assert server.default_url == url - - for variable_name, variable in iteritems(server.variables): - assert type(variable) == ServerVariable - assert variable.name == variable_name - - variable_spec = server_spec['variables'][variable_name] - assert variable.default == variable_spec['default'] - assert variable.enum == variable_spec.get('enum') - - for path_name, path in iteritems(spec.paths): - assert type(path) == Path - - path_spec = spec_dict['paths'][path_name] - assert path.name == path_name - assert path.summary == path_spec.get('summary') - assert path.description == path_spec.get('description') - - servers_spec = path_spec.get('servers', []) - for idx, server in enumerate(path.servers): - assert type(server) == Server - + def schema_path(self, spec_dict, base_uri): + return SchemaPath.from_dict(spec_dict, base_uri=base_uri) + + def test_spec(self, schema_path, spec_dict): + url = "http://petstore.swagger.io/v1" + + info = schema_path / "info" + info_spec = spec_dict["info"] + assert info["title"] == info_spec["title"] + assert info["description"] == info_spec["description"] + assert info["termsOfService"] == info_spec["termsOfService"] + assert info["version"] == info_spec["version"] + + contact = info / "contact" + contact_spec = info_spec["contact"] + assert contact["name"] == contact_spec["name"] + assert contact["url"] == contact_spec["url"] + assert contact["email"] == contact_spec["email"] + + license = info / "license" + license_spec = info_spec["license"] + assert license["name"] == license_spec["name"] + assert license["url"] == license_spec["url"] + + security = schema_path / "security" + security_spec = spec_dict.get("security", []) + for idx, security_reqs in enumerate(security): + security_reqs_spec = security_spec[idx] + for scheme_name, security_req in security_reqs.items(): + security_req == security_reqs_spec[scheme_name] + + assert get_spec_url(schema_path) == url + + servers = schema_path / "servers" + for idx, server in enumerate(servers): + server_spec = spec_dict["servers"][idx] + assert server["url"] == server_spec["url"] + assert get_server_url(server) == url + + variables = server / "variables" + for variable_name, variable in variables.items(): + variable_spec = server_spec["variables"][variable_name] + assert variable["default"] == variable_spec["default"] + assert variable["enum"] == variable_spec.get("enum") + + paths = schema_path / "paths" + for path_name, path in paths.items(): + path_spec = spec_dict["paths"][path_name] + assert path.getkey("summary") == path_spec.get("summary") + assert path.getkey("description") == path_spec.get("description") + + servers = path.get("servers", []) + servers_spec = path_spec.get("servers", []) + for idx, server in enumerate(servers): server_spec = servers_spec[idx] - assert server.url == server_spec['url'] - assert server.default_url == server_spec['url'] - assert server.description == server_spec.get('description') - - for variable_name, variable in iteritems(server.variables): - assert type(variable) == ServerVariable - assert variable.name == variable_name - - variable_spec = server_spec['variables'][variable_name] - assert variable.default == variable_spec['default'] - assert variable.enum == variable_spec.get('enum') - - for http_method, operation in iteritems(path.operations): + assert server.url == server_spec["url"] + assert server.default_url == server_spec["url"] + assert server.description == server_spec.get("description") + + variables = server.get("variables", {}) + for variable_name, variable in variables.items(): + variable_spec = server_spec["variables"][variable_name] + assert variable["default"] == variable_spec["default"] + assert variable.getkey("enum") == variable_spec.get("enum") + + operations = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ] + for http_method in operations: + if http_method not in path: + continue + operation = path / http_method operation_spec = path_spec[http_method] - assert type(operation) == Operation - assert operation.path_name == path_name - assert operation.http_method == http_method - assert operation.operation_id is not None - assert operation.tags == operation_spec['tags'] - assert operation.summary == operation_spec.get('summary') - assert operation.description == operation_spec.get( - 'description') + assert operation["operationId"] is not None + assert operation["tags"] == operation_spec["tags"] + assert operation["summary"] == operation_spec.get("summary") + assert operation.getkey("description") == operation_spec.get( + "description" + ) - ext_docs_spec = operation_spec.get('externalDocs') + ext_docs = operation.get("externalDocs") + ext_docs_spec = operation_spec.get("externalDocs") + assert bool(ext_docs_spec) == bool(ext_docs) if ext_docs_spec: - ext_docs = operation.external_docs - assert ext_docs.url == ext_docs_spec['url'] - assert ext_docs.description == ext_docs_spec.get( - 'description') - - servers_spec = operation_spec.get('servers', []) - for idx, server in enumerate(operation.servers): - assert type(server) == Server - + assert ext_docs["url"] == ext_docs_spec["url"] + assert ext_docs.getkey("description") == ext_docs_spec.get( + "description" + ) + + servers = operation.get("servers", []) + servers_spec = operation_spec.get("servers", []) + for idx, server in enumerate(servers): server_spec = servers_spec[idx] - assert server.url == server_spec['url'] - assert server.default_url == server_spec['url'] - assert server.description == server_spec.get('description') - - for variable_name, variable in iteritems(server.variables): - assert type(variable) == ServerVariable - assert variable.name == variable_name - - variable_spec = server_spec['variables'][variable_name] - assert variable.default == variable_spec['default'] - assert variable.enum == variable_spec.get('enum') - - security_spec = operation_spec.get('security') + assert server["url"] == server_spec["url"] + assert get_server_url(server) == server_spec["url"] + assert server["description"] == server_spec.get( + "description" + ) + + variables = server.get("variables", {}) + for variable_name, variable in variables.items(): + variable_spec = server_spec["variables"][variable_name] + assert variable["default"] == variable_spec["default"] + assert variable.getkey("enum") == variable_spec.get( + "enum" + ) + + security = operation.get("security", []) + security_spec = operation_spec.get("security") if security_spec is not None: - for idx, security_req in enumerate(operation.security): - assert type(security_req) == SecurityRequirement - - security_req_spec = security_spec[idx] - for scheme_name in security_req: - security_req[scheme_name] == security_req_spec[ - scheme_name] - - responses_spec = operation_spec.get('responses') - - for http_status, response in iteritems(operation.responses): - assert type(response) == Response - assert response.http_status == http_status - + for idx, security_reqs in enumerate(security): + security_reqs_spec = security_spec[idx] + for scheme_name, security_req in security_reqs.items(): + security_req == security_reqs_spec[scheme_name] + + responses = operation / "responses" + responses_spec = operation_spec.get("responses") + for http_status, response in responses.items(): response_spec = responses_spec[http_status] if not response_spec: continue # @todo: test with defererence - if '$ref' in response_spec: + if "$ref" in response_spec: continue - description_spec = response_spec['description'] - - assert response.description == description_spec + description_spec = response_spec["description"] - for mimetype, media_type in iteritems(response.content): - assert type(media_type) == MediaType - assert media_type.mimetype == mimetype + assert response.getkey("description") == description_spec - content_spec = response_spec['content'][mimetype] - - example_spec = content_spec.get('example') - assert media_type.example == example_spec + headers = response.get("headers", {}) + for parameter_name, parameter in headers.items(): + headers_spec = response_spec["headers"] + parameter_spec = headers_spec[parameter_name] - schema_spec = content_spec.get('schema') - assert bool(schema_spec) == bool(media_type.schema) + schema = parameter.get("schema") + schema_spec = parameter_spec.get("schema") + assert bool(schema_spec) == bool(schema) if not schema_spec: continue # @todo: test with defererence - if '$ref' in schema_spec: + if "$ref" in schema_spec: continue - assert type(media_type.schema) == Schema - assert media_type.schema.type.value ==\ - schema_spec['type'] - assert media_type.schema.required == schema_spec.get( - 'required', []) + assert schema["type"] == schema_spec["type"] + assert schema.getkey("format") == schema_spec.get( + "format" + ) + assert schema.getkey("required") == schema_spec.get( + "required" + ) - for parameter_name, parameter in iteritems( - response.headers): - assert type(parameter) == Parameter - assert parameter.name == parameter_name + content = parameter.get("content", {}) + content_spec = parameter_spec.get("content") + assert bool(content_spec) == bool(content) - headers_spec = response_spec['headers'] - parameter_spec = headers_spec[parameter_name] - schema_spec = parameter_spec.get('schema') - assert bool(schema_spec) == bool(parameter.schema) + if not content_spec: + continue + + for mimetype, media_type in content.items(): + media_spec = parameter_spec["content"][mimetype] + schema = media_type.get("schema") + schema_spec = media_spec.get("schema") + assert bool(schema_spec) == bool(schema) + + if not schema_spec: + continue + + # @todo: test with defererence + if "$ref" in schema_spec: + continue + + assert schema["type"] == schema_spec["type"] + assert schema.getkey("format") == schema_spec.get( + "format" + ) + assert schema.getkey( + "required" + ) == schema_spec.get("required") + + content_spec = response_spec.get("content") + + if not content_spec: + continue + + content = response.get("content", {}) + for mimetype, media_type in content.items(): + content_spec = response_spec["content"][mimetype] + + example_spec = content_spec.get("example") + assert media_type.getkey("example") == example_spec + + schema = media_type.get("schema") + schema_spec = content_spec.get("schema") + assert bool(schema_spec) == bool(schema) if not schema_spec: continue # @todo: test with defererence - if '$ref' in schema_spec: + if "$ref" in schema_spec: continue - assert type(parameter.schema) == Schema - assert parameter.schema.type.value ==\ - schema_spec['type'] - assert parameter.schema.format ==\ - schema_spec.get('format') - assert parameter.schema.required == schema_spec.get( - 'required', []) + assert schema["type"] == schema_spec["type"] + assert schema.getkey("required") == schema_spec.get( + "required" + ) - request_body_spec = operation_spec.get('requestBody') - - assert bool(request_body_spec) == bool(operation.request_body) + request_body = operation.get("requestBody") + request_body_spec = operation_spec.get("requestBody") + assert bool(request_body_spec) == bool(request_body) if not request_body_spec: continue - assert type(operation.request_body) == RequestBody - assert bool(operation.request_body.required) ==\ - request_body_spec.get('required', False) - - for mimetype, media_type in iteritems( - operation.request_body.content): - assert type(media_type) == MediaType - assert media_type.mimetype == mimetype + assert bool( + request_body.getkey("required") + ) == request_body_spec.get("required") - content_spec = request_body_spec['content'][mimetype] - schema_spec = content_spec.get('schema') - assert bool(schema_spec) == bool(media_type.schema) + content = request_body / "content" + for mimetype, media_type in content.items(): + content_spec = request_body_spec["content"][mimetype] + schema_spec = content_spec.get("schema") if not schema_spec: continue # @todo: test with defererence - if '$ref' in schema_spec: + if "$ref" in schema_spec: continue - assert type(media_type.schema) == Schema - assert media_type.schema.type.value ==\ - schema_spec['type'] - assert media_type.schema.format ==\ - schema_spec.get('format') - assert media_type.schema.required == schema_spec.get( - 'required', False) + schema = media_type.get("schema") + assert bool(schema_spec) == bool(schema) - if not spec.components: + assert schema["type"] == schema_spec["type"] + assert schema.getkey("format") == schema_spec.get("format") + assert schema.getkey("required") == schema_spec.get( + "required" + ) + + components = schema_path.get("components") + if not components: return - for schema_name, schema in iteritems(spec.components.schemas): - assert type(schema) == Schema + schemas = components.get("schemas", {}) + for schema_name, schema in schemas.items(): + schema_spec = spec_dict["components"]["schemas"][schema_name] + assert schema.getkey("readOnly") == schema_spec.get("readOnly") + assert schema.getkey("writeOnly") == schema_spec.get("writeOnly") + + +class TestWebhook: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + @pytest.fixture + def base_uri(self): + return "file://tests/integration/data/v3.1/webhook-example.yaml" + + @pytest.fixture + def spec_dict(self, content_factory): + content, _ = content_factory.from_file( + "data/v3.1/webhook-example.yaml" + ) + return content + + @pytest.fixture + def schema_path(self, spec_dict, base_uri): + return SchemaPath.from_dict( + spec_dict, + base_uri=base_uri, + ) + + def test_spec(self, schema_path, spec_dict): + info = schema_path / "info" + info_spec = spec_dict["info"] + assert info["title"] == info_spec["title"] + assert info["version"] == info_spec["version"] + + webhooks = schema_path / "webhooks" + webhooks_spec = spec_dict["webhooks"] + assert webhooks["newPet"] == webhooks_spec["newPet"] + + components = schema_path.get("components") + if not components: + return - schema_spec = spec_dict['components']['schemas'][schema_name] - assert schema.read_only == schema_spec.get('readOnly', False) - assert schema.write_only == schema_spec.get('writeOnly', False) + schemas = components.get("schemas", {}) + for schema_name, schema in schemas.items(): + assert spec_dict["components"]["schemas"][schema_name] is not None diff --git a/tests/integration/test_minimal.py b/tests/integration/test_minimal.py new file mode 100644 index 00000000..8d80c3d2 --- /dev/null +++ b/tests/integration/test_minimal.py @@ -0,0 +1,52 @@ +import pytest + +from openapi_core import unmarshal_request +from openapi_core import validate_request +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.testing import MockRequest + + +class TestMinimal: + servers = [ + "http://minimal.test/", + "https://bad.remote.domain.net/", + "http://localhost", + "http://localhost:8080", + "https://u:p@a.b:1337", + ] + + spec_paths = [ + "data/v3.0/minimal_with_servers.yaml", + "data/v3.0/minimal.yaml", + "data/v3.1/minimal_with_servers.yaml", + "data/v3.1/minimal.yaml", + ] + + @pytest.mark.parametrize("server", servers) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_hosts(self, schema_path_factory, server, spec_path): + spec = schema_path_factory.from_file(spec_path) + request = MockRequest(server, "get", "/status") + + result = unmarshal_request(request, spec=spec) + + assert not result.errors + + @pytest.mark.parametrize("server", servers) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_invalid_operation(self, schema_path_factory, server, spec_path): + spec = schema_path_factory.from_file(spec_path) + request = MockRequest(server, "post", "/status") + + with pytest.raises(OperationNotFound): + validate_request(request, spec) + + @pytest.mark.parametrize("server", servers) + @pytest.mark.parametrize("spec_path", spec_paths) + def test_invalid_path(self, schema_path_factory, server, spec_path): + spec = schema_path_factory.from_file(spec_path) + request = MockRequest(server, "get", "/nonexistent") + + with pytest.raises(PathNotFound): + validate_request(request, spec=spec) diff --git a/tests/integration/test_petstore.py b/tests/integration/test_petstore.py new file mode 100644 index 00000000..58fbb760 --- /dev/null +++ b/tests/integration/test_petstore.py @@ -0,0 +1,2131 @@ +import json +from base64 import b64encode +from dataclasses import is_dataclass +from datetime import datetime +from urllib.parse import urlencode +from uuid import UUID + +import pytest +from isodate.tzinfo import UTC + +from openapi_core import unmarshal_request +from openapi_core import unmarshal_response +from openapi_core import validate_request +from openapi_core import validate_response +from openapi_core.casting.schemas.exceptions import CastError +from openapi_core.datatypes import Parameters +from openapi_core.deserializing.styles.exceptions import ( + EmptyQueryParameterValue, +) +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestBodyUnmarshaller, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestParametersUnmarshaller, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestSecurityUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseDataUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseHeadersUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseUnmarshaller, +) +from openapi_core.validation.request.exceptions import MissingRequiredParameter +from openapi_core.validation.request.exceptions import ParameterValidationError +from openapi_core.validation.request.exceptions import ( + RequestBodyValidationError, +) +from openapi_core.validation.request.exceptions import SecurityValidationError +from openapi_core.validation.request.validators import V30RequestBodyValidator +from openapi_core.validation.request.validators import ( + V30RequestParametersValidator, +) +from openapi_core.validation.request.validators import ( + V30RequestSecurityValidator, +) +from openapi_core.validation.response.exceptions import InvalidData +from openapi_core.validation.response.exceptions import MissingRequiredHeader +from openapi_core.validation.response.validators import ( + V30ResponseDataValidator, +) +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue + + +class TestPetstore: + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + @pytest.fixture(scope="module") + def spec_dict(self, v30_petstore_content): + return v30_petstore_content + + @pytest.fixture(scope="module") + def spec(self, v30_petstore_spec): + return v30_petstore_spec + + @pytest.fixture(scope="module") + def response_unmarshaller(self, spec): + return V30ResponseUnmarshaller(spec) + + def test_get_pets(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestBodyUnmarshaller, + ) + + assert result.body is None + + data_json = { + "data": [], + } + data = json.dumps(data_json).encode() + headers = { + "Content-Type": "application/json", + "x-next": "next-url", + } + response = MockResponse(data, headers=headers) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.data == [] + assert response_result.headers == { + "x-next": "next-url", + } + + def test_get_pets_response(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data_json = { + "data": [ + { + "id": 1, + "name": "Cat", + "ears": { + "healthy": True, + }, + } + ], + } + data = json.dumps(data_json).encode() + response = MockResponse(data) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert len(response_result.data.data) == 1 + assert response_result.data.data[0].id == 1 + assert response_result.data.data[0].name == "Cat" + + def test_get_pets_response_media_type(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data = b"\xb1\xbc" + response = MockResponse( + data, status_code=404, content_type="text/html; charset=iso-8859-2" + ) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert response_result.data == data.decode("iso-8859-2") + + def test_get_pets_invalid_response(self, spec, response_unmarshaller): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + response_data_json = { + "data": [ + { + "id": 1, + "name": { + "first_name": "Cat", + }, + } + ], + } + response_data = json.dumps(response_data_json).encode() + response = MockResponse(response_data) + + with pytest.raises(InvalidData) as exc_info: + validate_response( + request, + response, + spec=spec, + cls=V30ResponseDataValidator, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + response_result = response_unmarshaller.unmarshal(request, response) + + assert response_result.errors == [InvalidData()] + schema_errors = response_result.errors[0].__cause__.schema_errors + assert response_result.errors[0].__cause__ == InvalidSchemaValue( + type="object", + value=response_data_json, + schema_errors=schema_errors, + ) + assert response_result.data is None + + def test_get_pets_ids_param(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + "ids": ["12", "13"], + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + "ids": [12, 13], + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data_json = { + "data": [], + } + data = json.dumps(data_json).encode() + response = MockResponse(data) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.data == [] + + def test_get_pets_tags_param(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = [ + ("limit", "20"), + ("tags", "cats,dogs"), + ] + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": 20, + "page": 1, + "search": "", + "tags": ["cats", "dogs"], + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data_json = { + "data": [], + } + data = json.dumps(data_json).encode() + response = MockResponse(data) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.data == [] + + def test_get_pets_parameter_schema_error(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "1", + "tags": ",,", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + with pytest.raises(ParameterValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_wrong_parameter_type(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "twenty", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + with pytest.raises(ParameterValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + assert type(exc_info.value.__cause__) is CastError + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_raises_missing_required_param(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + with pytest.raises(MissingRequiredParameter): + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_empty_value(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "1", + "order": "", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + with pytest.raises(ParameterValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + assert type(exc_info.value.__cause__) is EmptyQueryParameterValue + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_allow_empty_value(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": "20", + "search": "", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "page": 1, + "limit": 20, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_none_value(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": None, + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": None, + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_param_order(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + query_params = { + "limit": None, + "order": "desc", + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + query={ + "limit": None, + "order": "desc", + "page": 1, + "search": "", + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_get_pets_param_coordinates(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets" + coordinates = { + "lat": 1.12, + "lon": 32.12, + } + query_params = { + "limit": None, + "coordinates": json.dumps(coordinates), + } + + request = MockRequest( + host_url, + "GET", + "/pets", + path_pattern=path_pattern, + args=query_params, + ) + + with pytest.warns( + DeprecationWarning, match="limit parameter is deprecated" + ): + with pytest.warns( + DeprecationWarning, + match="Use of allowEmptyValue property is deprecated", + ): + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert is_dataclass(result.parameters.query["coordinates"]) + assert ( + result.parameters.query["coordinates"].__class__.__name__ + == "Coordinates" + ) + assert result.parameters.query["coordinates"].lat == coordinates["lat"] + assert result.parameters.query["coordinates"].lon == coordinates["lon"] + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_post_birds(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + userdata = { + "name": "user1", + } + userdata_json = json.dumps(userdata) + cookies = { + "user": "123", + "userdata": userdata_json, + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert is_dataclass(result.parameters.cookie["userdata"]) + assert ( + result.parameters.cookie["userdata"].__class__.__name__ + == "Userdata" + ) + assert result.parameters.cookie["userdata"].name == "user1" + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + assert result.body.healthy == pet_healthy + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestSecurityUnmarshaller, + ) + + assert result.security == {} + + def test_post_cats(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "ears": { + "healthy": pet_healthy, + }, + "extra": None, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + assert result.body.healthy == pet_healthy + assert result.body.extra is None + + def test_post_cats_boolean_string(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "ears": { + "healthy": pet_healthy, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + assert result.body.healthy is False + + @pytest.mark.xfail( + reason="urlencoded object with oneof not supported", + strict=True, + ) + def test_post_urlencoded(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + pet_healthy = False + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "healthy": pet_healthy, + "wings": { + "healthy": pet_healthy, + }, + } + data = urlencode(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + userdata = { + "name": "user1", + } + userdata_json = json.dumps(userdata) + cookies = { + "user": "123", + "userdata": userdata_json, + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + content_type="application/x-www-form-urlencoded", + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert is_dataclass(result.parameters.cookie["userdata"]) + assert ( + result.parameters.cookie["userdata"].__class__.__name__ + == "Userdata" + ) + assert result.parameters.cookie["userdata"].name == "user1" + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + assert result.body.healthy == pet_healthy + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestSecurityUnmarshaller, + ) + + assert result.security == {} + + def test_post_no_one_of_schema(self, spec): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + alias = "kitty" + data_json = { + "name": pet_name, + "alias": alias, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + def test_post_cats_only_required_body(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_healthy = True + data_json = { + "name": pet_name, + "ears": { + "healthy": pet_healthy, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert not hasattr(result.body, "tag") + assert not hasattr(result.body, "address") + + def test_post_pets_raises_invalid_mimetype(self, spec): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + data_json = { + "name": "Cat", + "tag": "cats", + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + content_type="text/html", + headers=headers, + cookies=cookies, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is MediaTypeNotFound + + def test_post_pets_missing_cookie(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_healthy = True + data_json = { + "name": pet_name, + "ears": { + "healthy": pet_healthy, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + headers=headers, + ) + + with pytest.raises(MissingRequiredParameter): + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert not hasattr(result.body, "tag") + assert not hasattr(result.body, "address") + + def test_post_pets_missing_header(self, spec, spec_dict): + host_url = "https://staging.gigantic-server.com/v1" + path_pattern = "/v1/pets" + pet_name = "Cat" + pet_healthy = True + data_json = { + "name": pet_name, + "ears": { + "healthy": pet_healthy, + }, + } + data = json.dumps(data_json).encode() + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + cookies=cookies, + ) + + with pytest.raises(MissingRequiredParameter): + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert not hasattr(result.body, "tag") + assert not hasattr(result.body, "address") + + def test_post_pets_raises_invalid_server_error(self, spec): + host_url = "http://flowerstore.swagger.io/v1" + path_pattern = "/v1/pets" + data_json = { + "name": "Cat", + "tag": "cats", + } + data = json.dumps(data_json).encode() + headers = { + "api-key": "12345", + } + cookies = { + "user": "123", + } + + request = MockRequest( + host_url, + "POST", + "/pets", + path_pattern=path_pattern, + data=data, + content_type="text/html", + headers=headers, + cookies=cookies, + ) + + with pytest.raises(ServerNotFound): + validate_request( + request, + spec=spec, + cls=V30RequestParametersValidator, + ) + + with pytest.raises(ServerNotFound): + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + + data_id = 1 + data_name = "test" + data_json = { + "data": { + "id": data_id, + "name": data_name, + "ears": { + "healthy": True, + }, + }, + } + data = json.dumps(data_json).encode() + response = MockResponse(data) + + with pytest.raises(ServerNotFound): + validate_response( + request, + response, + spec=spec, + cls=V30ResponseDataValidator, + ) + + def test_get_pet_invalid_security(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets/{petId}" + view_args = { + "petId": "1", + } + request = MockRequest( + host_url, + "GET", + "/pets/1", + path_pattern=path_pattern, + view_args=view_args, + ) + + with pytest.raises(SecurityValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestSecurityValidator, + ) + + assert exc_info.value.__cause__ == SecurityNotFound( + [["petstore_auth"]] + ) + + def test_get_pet(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets/{petId}" + view_args = { + "petId": "1", + } + auth = "authuser" + headers = { + "Authorization": f"Basic {auth}", + } + request = MockRequest( + host_url, + "GET", + "/pets/1", + path_pattern=path_pattern, + view_args=view_args, + headers=headers, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + path={ + "petId": 1, + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestSecurityUnmarshaller, + ) + + assert result.security == { + "petstore_auth": auth, + } + + data_id = 1 + data_name = "test" + data_json = { + "data": { + "id": data_id, + "name": data_name, + "ears": { + "healthy": True, + }, + }, + } + data = json.dumps(data_json).encode() + response = MockResponse(data) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert is_dataclass(response_result.data.data) + assert response_result.data.data.id == data_id + assert response_result.data.data.name == data_name + + def test_get_pet_not_found(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets/{petId}" + view_args = { + "petId": "1", + } + request = MockRequest( + host_url, + "GET", + "/pets/1", + path_pattern=path_pattern, + view_args=view_args, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + path={ + "petId": 1, + } + ) + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + code = 404 + message = "Not found" + rootCause = "Pet not found" + data_json = { + "code": 404, + "message": message, + "rootCause": rootCause, + } + data = json.dumps(data_json).encode() + response = MockResponse(data, status_code=404) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.rootCause == rootCause + + def test_get_pet_wildcard(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/pets/{petId}" + view_args = { + "petId": "1", + } + request = MockRequest( + host_url, + "GET", + "/pets/1", + path_pattern=path_pattern, + view_args=view_args, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters( + path={ + "petId": 1, + } + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestBodyUnmarshaller, + ) + + assert result.body is None + + data = b"imagedata" + response = MockResponse(data, content_type="image/png") + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert response_result.data == data + + def test_get_tags(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + + request = MockRequest( + host_url, + "GET", + "/tags", + path_pattern=path_pattern, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data_json = ["cats", "birds"] + data = json.dumps(data_json).encode() + response = MockResponse(data) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert response_result.data == data_json + + def test_post_tags_extra_body_properties(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + pet_name = "Dog" + alias = "kitty" + data_json = { + "name": pet_name, + "alias": alias, + } + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + def test_post_tags_empty_body(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + data_json = {} + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + def test_post_tags_wrong_property_type(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + tag_name = 123 + data = json.dumps(tag_name).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is CastError + + def test_post_tags_additional_properties(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + pet_name = "Dog" + data_json = { + "name": pet_name, + } + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert is_dataclass(result.body) + assert result.body.name == pet_name + + code = 400 + message = "Bad request" + rootCause = "Tag already exist" + additionalinfo = "Tag Dog already exist" + data_json = { + "code": code, + "message": message, + "rootCause": rootCause, + "additionalinfo": additionalinfo, + } + data = json.dumps(data_json).encode() + response = MockResponse(data, status_code=404) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.rootCause == rootCause + assert response_result.data.additionalinfo == additionalinfo + + def test_post_tags_created_now(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + created = "now" + pet_name = "Dog" + data_json = { + "created": created, + "name": pet_name, + } + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert is_dataclass(result.body) + assert result.body.created == created + assert result.body.name == pet_name + + code = 400 + message = "Bad request" + rootCause = "Tag already exist" + additionalinfo = "Tag Dog already exist" + data_json = { + "code": 400, + "message": "Bad request", + "rootCause": "Tag already exist", + "additionalinfo": "Tag Dog already exist", + } + data = json.dumps(data_json).encode() + response = MockResponse(data, status_code=404) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.rootCause == rootCause + assert response_result.data.additionalinfo == additionalinfo + + def test_post_tags_created_datetime(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + created = "2016-04-16T16:06:05Z" + pet_name = "Dog" + data_json = { + "created": created, + "name": pet_name, + } + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert is_dataclass(result.body) + assert result.body.created == datetime( + 2016, 4, 16, 16, 6, 5, tzinfo=UTC + ) + assert result.body.name == pet_name + + code = 400 + message = "Bad request" + rootCause = "Tag already exist" + additionalinfo = "Tag Dog already exist" + response_data_json = { + "code": code, + "message": message, + "rootCause": rootCause, + "additionalinfo": additionalinfo, + } + response_data = json.dumps(response_data_json).encode() + response = MockResponse(response_data, status_code=404) + + result = unmarshal_response( + request, + response, + spec=spec, + cls=V30ResponseDataUnmarshaller, + ) + + assert is_dataclass(result.data) + assert result.data.code == code + assert result.data.message == message + assert result.data.rootCause == rootCause + assert result.data.additionalinfo == additionalinfo + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.rootCause == rootCause + assert response_result.data.additionalinfo == additionalinfo + + def test_post_tags_urlencoded(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + created = "2016-04-16T16:06:05Z" + pet_name = "Dog" + data_json = { + "created": created, + "name": pet_name, + } + data = urlencode(data_json).encode() + content_type = "application/x-www-form-urlencoded" + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + content_type=content_type, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert is_dataclass(result.body) + assert result.body.created == datetime( + 2016, 4, 16, 16, 6, 5, tzinfo=UTC + ) + assert result.body.name == pet_name + + code = 400 + message = "Bad request" + rootCause = "Tag already exist" + additionalinfo = "Tag Dog already exist" + response_data_json = { + "code": code, + "message": message, + "rootCause": rootCause, + "additionalinfo": additionalinfo, + } + response_data = json.dumps(response_data_json).encode() + response = MockResponse(response_data, status_code=404) + + result = unmarshal_response( + request, + response, + spec=spec, + cls=V30ResponseDataUnmarshaller, + ) + + assert is_dataclass(result.data) + assert result.data.code == code + assert result.data.message == message + assert result.data.rootCause == rootCause + assert result.data.additionalinfo == additionalinfo + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.rootCause == rootCause + assert response_result.data.additionalinfo == additionalinfo + + def test_post_tags_created_invalid_type(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + created = "long time ago" + pet_name = "Dog" + data_json = { + "created": created, + "name": pet_name, + } + data = json.dumps(data_json).encode() + + request = MockRequest( + host_url, + "POST", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + with pytest.raises(RequestBodyValidationError) as exc_info: + validate_request( + request, + spec=spec, + cls=V30RequestBodyValidator, + ) + assert type(exc_info.value.__cause__) is InvalidSchemaValue + + code = 400 + message = "Bad request" + correlationId = UUID("a8098c1a-f86e-11da-bd1a-00112444be1e") + rootCause = "Tag already exist" + additionalinfo = "Tag Dog already exist" + data_json = { + "message": message, + "correlationId": str(correlationId), + "rootCause": rootCause, + "additionalinfo": additionalinfo, + } + data = json.dumps(data_json).encode() + response = MockResponse(data, status_code=404) + + response_result = unmarshal_response(request, response, spec=spec) + + assert response_result.errors == [] + assert is_dataclass(response_result.data) + assert response_result.data.code == code + assert response_result.data.message == message + assert response_result.data.correlationId == correlationId + assert response_result.data.rootCause == rootCause + assert response_result.data.additionalinfo == additionalinfo + + def test_delete_tags_with_requestbody(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + ids = [1, 2, 3] + data_json = { + "ids": ids, + } + data = json.dumps(data_json).encode() + request = MockRequest( + host_url, + "DELETE", + "/tags", + path_pattern=path_pattern, + data=data, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert is_dataclass(result.body) + assert result.body.ids == ids + + data = None + headers = { + "x-delete-confirm": "true", + } + response = MockResponse(data, status_code=200, headers=headers) + + with pytest.warns( + DeprecationWarning, match="x-delete-confirm header is deprecated" + ): + response_result = unmarshal_response(request, response, spec=spec) + assert response_result.errors == [] + assert response_result.data is None + + with pytest.warns( + DeprecationWarning, match="x-delete-confirm header is deprecated" + ): + result = unmarshal_response( + request, + response, + spec=spec, + cls=V30ResponseHeadersUnmarshaller, + ) + + assert result.headers == { + "x-delete-confirm": True, + } + + def test_delete_tags_no_requestbody(self, spec): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + request = MockRequest( + host_url, + "DELETE", + "/tags", + path_pattern=path_pattern, + ) + + validate_request(request, spec=spec) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + def test_delete_tags_raises_missing_required_response_header( + self, spec, response_unmarshaller + ): + host_url = "http://petstore.swagger.io/v1" + path_pattern = "/v1/tags" + request = MockRequest( + host_url, + "DELETE", + "/tags", + path_pattern=path_pattern, + ) + + result = unmarshal_request( + request, + spec=spec, + cls=V30RequestParametersUnmarshaller, + ) + + assert result.parameters == Parameters() + + result = unmarshal_request( + request, spec=spec, cls=V30RequestBodyUnmarshaller + ) + + assert result.body is None + + data = None + response = MockResponse(data, status_code=200) + + with pytest.warns(DeprecationWarning): + response_result = response_unmarshaller.unmarshal( + request, response + ) + + assert response_result.errors == [ + MissingRequiredHeader(name="x-delete-confirm"), + ] + assert response_result.data is None diff --git a/tests/integration/unmarshalling/test_read_only_write_only.py b/tests/integration/unmarshalling/test_read_only_write_only.py new file mode 100644 index 00000000..6297654e --- /dev/null +++ b/tests/integration/unmarshalling/test_read_only_write_only.py @@ -0,0 +1,109 @@ +import json +from dataclasses import is_dataclass + +import pytest + +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseUnmarshaller, +) +from openapi_core.validation.request.exceptions import InvalidRequestBody +from openapi_core.validation.response.exceptions import InvalidData + + +@pytest.fixture(scope="class") +def schema_path(schema_path_factory): + return schema_path_factory.from_file("data/v3.0/read_only_write_only.yaml") + + +@pytest.fixture(scope="class") +def request_unmarshaller(schema_path): + return V30RequestUnmarshaller(schema_path) + + +@pytest.fixture(scope="class") +def response_unmarshaller(schema_path): + return V30ResponseUnmarshaller(schema_path) + + +class TestReadOnly: + def test_write_a_read_only_property(self, request_unmarshaller): + data = json.dumps( + { + "id": 10, + "name": "Pedro", + } + ).encode() + + request = MockRequest( + host_url="", method="POST", path="/users", data=data + ) + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == InvalidRequestBody + assert result.body is None + + def test_read_only_property_response(self, response_unmarshaller): + data = json.dumps( + { + "id": 10, + "name": "Pedro", + } + ).encode() + + request = MockRequest(host_url="", method="POST", path="/users") + + response = MockResponse(data) + + result = response_unmarshaller.unmarshal(request, response) + + assert not result.errors + assert is_dataclass(result.data) + assert result.data.__class__.__name__ == "User" + assert result.data.id == 10 + assert result.data.name == "Pedro" + + +class TestWriteOnly: + def test_write_only_property(self, request_unmarshaller): + data = json.dumps( + { + "name": "Pedro", + "hidden": False, + } + ).encode() + + request = MockRequest( + host_url="", method="POST", path="/users", data=data + ) + + result = request_unmarshaller.unmarshal(request) + + assert not result.errors + assert is_dataclass(result.body) + assert result.body.__class__.__name__ == "User" + assert result.body.name == "Pedro" + assert result.body.hidden == False + + def test_read_a_write_only_property(self, response_unmarshaller): + data = json.dumps( + { + "id": 10, + "name": "Pedro", + "hidden": True, + } + ).encode() + + request = MockRequest(host_url="", method="POST", path="/users") + response = MockResponse(data) + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [InvalidData()] + assert result.data is None diff --git a/tests/integration/unmarshalling/test_request_unmarshaller.py b/tests/integration/unmarshalling/test_request_unmarshaller.py new file mode 100644 index 00000000..0eefa3f0 --- /dev/null +++ b/tests/integration/unmarshalling/test_request_unmarshaller.py @@ -0,0 +1,419 @@ +import json +from base64 import b64encode + +import pytest + +from openapi_core import V30RequestUnmarshaller +from openapi_core.datatypes import Parameters +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.testing import MockRequest +from openapi_core.validation.request.exceptions import InvalidParameter +from openapi_core.validation.request.exceptions import MissingRequiredParameter +from openapi_core.validation.request.exceptions import ( + MissingRequiredRequestBody, +) +from openapi_core.validation.request.exceptions import ( + RequestBodyValidationError, +) +from openapi_core.validation.request.exceptions import SecurityValidationError + + +class TestRequestUnmarshaller: + host_url = "http://petstore.swagger.io" + + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + @pytest.fixture(scope="session") + def spec_dict(self, v30_petstore_content): + return v30_petstore_content + + @pytest.fixture(scope="session") + def spec(self, v30_petstore_spec): + return v30_petstore_spec + + @pytest.fixture(scope="session") + def request_unmarshaller(self, spec): + return V30RequestUnmarshaller(spec) + + def test_request_server_error(self, request_unmarshaller): + request = MockRequest("http://petstore.invalid.net/v1", "get", "/") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == PathNotFound + assert result.body is None + assert result.parameters == Parameters() + + def test_invalid_path(self, request_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == PathNotFound + assert result.body is None + assert result.parameters == Parameters() + + def test_invalid_operation(self, request_unmarshaller): + request = MockRequest(self.host_url, "patch", "/v1/pets") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == OperationNotFound + assert result.body is None + assert result.parameters == Parameters() + + def test_missing_parameter(self, request_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + + with pytest.warns(DeprecationWarning): + result = request_unmarshaller.unmarshal(request) + + assert type(result.errors[0]) == MissingRequiredParameter + assert result.body is None + assert result.parameters == Parameters( + query={ + "page": 1, + "search": "", + }, + ) + + def test_get_pets(self, request_unmarshaller): + args = {"limit": "10", "ids": ["1", "2"], "api_key": self.api_key} + request = MockRequest( + self.host_url, + "get", + "/v1/pets", + path_pattern="/v1/pets", + args=args, + ) + + with pytest.warns(DeprecationWarning): + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [] + assert result.body is None + assert result.parameters == Parameters( + query={ + "limit": 10, + "page": 1, + "search": "", + "ids": [1, 2], + }, + ) + assert result.security == { + "api_key": self.api_key, + } + + def test_get_pets_multidict(self, request_unmarshaller): + from multidict import MultiDict + + request = MockRequest( + self.host_url, + "get", + "/v1/pets", + path_pattern="/v1/pets", + ) + request.parameters.query = MultiDict( + [("limit", "5"), ("ids", "1"), ("ids", "2")], + ) + + with pytest.warns(DeprecationWarning): + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [] + assert result.body is None + assert result.parameters == Parameters( + query={ + "limit": 5, + "page": 1, + "search": "", + "ids": [1, 2], + }, + ) + + def test_missing_body(self, request_unmarshaller): + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + headers=headers, + cookies=cookies, + ) + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == MissingRequiredRequestBody + assert result.body is None + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + def test_invalid_content_type(self, request_unmarshaller): + data = b"csv,data" + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + content_type="text/csv", + data=data, + headers=headers, + cookies=cookies, + ) + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == RequestBodyValidationError + assert result.errors[0].__cause__ == MediaTypeNotFound( + mimetype="text/csv", + availableMimetypes=[ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + ], + ) + assert result.body is None + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + + def test_invalid_complex_parameter(self, request_unmarshaller, spec_dict): + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "ears": { + "healthy": True, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + userdata = { + "name": 1, + } + userdata_json = json.dumps(userdata) + cookies = { + "user": "123", + "userdata": userdata_json, + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + data=data, + headers=headers, + cookies=cookies, + ) + + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [ + InvalidParameter(name="userdata", location="cookie") + ] + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + assert result.security == {} + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + + def test_post_pets(self, request_unmarshaller, spec_dict): + pet_name = "Cat" + pet_tag = "cats" + pet_street = "Piekna" + pet_city = "Warsaw" + data_json = { + "name": pet_name, + "tag": pet_tag, + "position": 2, + "address": { + "street": pet_street, + "city": pet_city, + }, + "ears": { + "healthy": True, + }, + } + data = json.dumps(data_json).encode() + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + data=data, + headers=headers, + cookies=cookies, + ) + + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [] + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + assert result.security == {} + + schemas = spec_dict["components"]["schemas"] + pet_model = schemas["PetCreate"]["x-model"] + address_model = schemas["Address"]["x-model"] + assert result.body.__class__.__name__ == pet_model + assert result.body.name == pet_name + assert result.body.tag == pet_tag + assert result.body.position == 2 + assert result.body.address.__class__.__name__ == address_model + assert result.body.address.street == pet_street + assert result.body.address.city == pet_city + + def test_post_pets_plain_no_schema(self, request_unmarshaller): + data = b"plain text" + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + data=data, + headers=headers, + cookies=cookies, + content_type="text/plain", + ) + + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [] + assert result.parameters == Parameters( + header={ + "api-key": self.api_key, + }, + cookie={ + "user": 123, + }, + ) + assert result.security == {} + assert result.body == data.decode() + + def test_get_pet_unauthorized(self, request_unmarshaller): + request = MockRequest( + self.host_url, + "get", + "/v1/pets/1", + path_pattern="/v1/pets/{petId}", + view_args={"petId": "1"}, + ) + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) is SecurityValidationError + assert result.errors[0].__cause__ == SecurityNotFound( + [["petstore_auth"]] + ) + assert result.body is None + assert result.parameters == Parameters() + assert result.security is None + + def test_get_pet(self, request_unmarshaller): + authorization = "Basic " + self.api_key_encoded + headers = { + "Authorization": authorization, + } + request = MockRequest( + self.host_url, + "get", + "/v1/pets/1", + path_pattern="/v1/pets/{petId}", + view_args={"petId": "1"}, + headers=headers, + ) + + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [] + assert result.body is None + assert result.parameters == Parameters( + path={ + "petId": 1, + }, + ) + assert result.security == { + "petstore_auth": self.api_key_encoded, + } diff --git a/tests/integration/unmarshalling/test_response_unmarshaller.py b/tests/integration/unmarshalling/test_response_unmarshaller.py new file mode 100644 index 00000000..3c67cf60 --- /dev/null +++ b/tests/integration/unmarshalling/test_response_unmarshaller.py @@ -0,0 +1,199 @@ +import json +from dataclasses import is_dataclass + +import pytest + +from openapi_core.deserializing.media_types.exceptions import ( + MediaTypeDeserializeError, +) +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.responses.exceptions import ResponseNotFound +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.unmarshalling.response.unmarshallers import ( + V30ResponseUnmarshaller, +) +from openapi_core.validation.response.exceptions import DataValidationError +from openapi_core.validation.response.exceptions import InvalidData +from openapi_core.validation.response.exceptions import InvalidHeader +from openapi_core.validation.response.exceptions import MissingData +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue + + +class TestResponseUnmarshaller: + host_url = "http://petstore.swagger.io" + + @pytest.fixture(scope="session") + def spec_dict(self, v30_petstore_content): + return v30_petstore_content + + @pytest.fixture(scope="session") + def spec(self, v30_petstore_spec): + return v30_petstore_spec + + @pytest.fixture(scope="session") + def response_unmarshaller(self, spec): + return V30ResponseUnmarshaller(spec) + + def test_invalid_server(self, response_unmarshaller): + request = MockRequest("http://petstore.invalid.net/v1", "get", "/") + response = MockResponse(b"Not Found", status_code=404) + + result = response_unmarshaller.unmarshal(request, response) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == PathNotFound + assert result.data is None + assert result.headers == {} + + def test_invalid_operation(self, response_unmarshaller): + request = MockRequest(self.host_url, "patch", "/v1/pets") + response = MockResponse(b"Not Found", status_code=404) + + result = response_unmarshaller.unmarshal(request, response) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == OperationNotFound + assert result.data is None + assert result.headers == {} + + def test_invalid_response(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"Not Found", status_code=409) + + result = response_unmarshaller.unmarshal(request, response) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == ResponseNotFound + assert result.data is None + assert result.headers == {} + + def test_invalid_content_type(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"Not Found", content_type="text/csv") + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [DataValidationError()] + assert type(result.errors[0].__cause__) == MediaTypeNotFound + assert result.data is None + assert result.headers == {} + + def test_missing_body(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(None) + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [MissingData()] + assert result.data is None + assert result.headers == {} + + def test_invalid_media_type(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"abcde") + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [DataValidationError()] + assert result.errors[0].__cause__ == MediaTypeDeserializeError( + mimetype="application/json", value=b"abcde" + ) + assert result.data is None + assert result.headers == {} + + def test_invalid_media_type_value(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"{}") + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [InvalidData()] + assert type(result.errors[0].__cause__) == InvalidSchemaValue + assert result.data is None + assert result.headers == {} + + def test_invalid_value(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/tags") + response_json = { + "data": [ + {"id": 1, "name": "Sparky"}, + ], + } + response_data = json.dumps(response_json) + response = MockResponse(response_data) + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [InvalidData()] + assert type(result.errors[0].__cause__) == InvalidSchemaValue + assert result.data is None + assert result.headers == {} + + def test_invalid_header(self, response_unmarshaller): + userdata = { + "name": 1, + } + userdata_json = json.dumps(userdata) + cookies = { + "user": "123", + "userdata": userdata_json, + } + request = MockRequest( + self.host_url, + "delete", + "/v1/tags", + path_pattern="/v1/tags", + cookies=cookies, + ) + response_json = { + "data": [ + { + "id": 1, + "name": "Sparky", + "ears": { + "healthy": True, + }, + }, + ], + } + response_data = json.dumps(response_json).encode() + headers = { + "x-delete-confirm": "true", + "x-delete-date": "today", + } + response = MockResponse(response_data, headers=headers) + + with pytest.warns(DeprecationWarning): + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [InvalidHeader(name="x-delete-date")] + assert result.data is None + assert result.headers == {"x-delete-confirm": True} + + def test_get_pets(self, response_unmarshaller): + request = MockRequest(self.host_url, "get", "/v1/pets") + response_json = { + "data": [ + { + "id": 1, + "name": "Sparky", + "ears": { + "healthy": True, + }, + }, + ], + } + response_data = json.dumps(response_json).encode() + response = MockResponse(response_data) + + result = response_unmarshaller.unmarshal(request, response) + + assert result.errors == [] + assert is_dataclass(result.data) + assert len(result.data.data) == 1 + assert result.data.data[0].id == 1 + assert result.data.data[0].name == "Sparky" + assert result.headers == {} diff --git a/tests/integration/unmarshalling/test_security_override.py b/tests/integration/unmarshalling/test_security_override.py new file mode 100644 index 00000000..8e549d6a --- /dev/null +++ b/tests/integration/unmarshalling/test_security_override.py @@ -0,0 +1,87 @@ +from base64 import b64encode + +import pytest + +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.testing import MockRequest +from openapi_core.unmarshalling.request.unmarshallers import ( + V30RequestUnmarshaller, +) +from openapi_core.validation.request.exceptions import SecurityValidationError + + +@pytest.fixture(scope="class") +def schema_path(schema_path_factory): + return schema_path_factory.from_file("data/v3.0/security_override.yaml") + + +@pytest.fixture(scope="class") +def request_unmarshaller(schema_path): + return V30RequestUnmarshaller(schema_path) + + +class TestSecurityOverride: + host_url = "http://petstore.swagger.io" + + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + def test_default(self, request_unmarshaller): + args = {"api_key": self.api_key} + request = MockRequest(self.host_url, "get", "/resource/one", args=args) + + result = request_unmarshaller.unmarshal(request) + + assert not result.errors + assert result.security == { + "api_key": self.api_key, + } + + def test_default_invalid(self, request_unmarshaller): + request = MockRequest(self.host_url, "get", "/resource/one") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) is SecurityValidationError + assert type(result.errors[0].__cause__) is SecurityNotFound + assert result.security is None + + def test_override(self, request_unmarshaller): + authorization = "Basic " + self.api_key_encoded + headers = { + "Authorization": authorization, + } + request = MockRequest( + self.host_url, "post", "/resource/one", headers=headers + ) + + result = request_unmarshaller.unmarshal(request) + + assert not result.errors + assert result.security == { + "petstore_auth": self.api_key_encoded, + } + + def test_override_invalid(self, request_unmarshaller): + request = MockRequest(self.host_url, "post", "/resource/one") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) is SecurityValidationError + assert type(result.errors[0].__cause__) is SecurityNotFound + assert result.security is None + + def test_remove(self, request_unmarshaller): + request = MockRequest(self.host_url, "put", "/resource/one") + + result = request_unmarshaller.unmarshal(request) + + assert not result.errors + assert result.security == {} diff --git a/tests/integration/unmarshalling/test_unmarshallers.py b/tests/integration/unmarshalling/test_unmarshallers.py new file mode 100644 index 00000000..54e944a3 --- /dev/null +++ b/tests/integration/unmarshalling/test_unmarshallers.py @@ -0,0 +1,2126 @@ +from datetime import date +from datetime import datetime +from uuid import UUID +from uuid import uuid4 + +import pytest +from isodate.tzinfo import UTC +from isodate.tzinfo import FixedOffset +from jsonschema.exceptions import SchemaError +from jsonschema.exceptions import UnknownType +from jsonschema_path import SchemaPath + +from openapi_core.unmarshalling.schemas import ( + oas30_read_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas import ( + oas30_write_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas import ( + oas31_schema_unmarshallers_factory, +) +from openapi_core.unmarshalling.schemas.exceptions import ( + FormatterNotFoundError, +) +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue + + +class BaseTestOASSchemaUnmarshallersFactoryCall: + def test_create_no_schema(self, unmarshallers_factory): + with pytest.raises(TypeError): + unmarshallers_factory.create(None) + + def test_create_schema_deprecated(self, unmarshallers_factory): + schema = { + "deprecated": True, + } + spec = SchemaPath.from_dict(schema) + with pytest.warns(DeprecationWarning): + unmarshallers_factory.create(spec) + + def test_create_formatter_not_found(self, unmarshallers_factory): + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises( + FormatterNotFoundError, + match="Formatter not found for custom format", + ): + unmarshallers_factory.create(spec) + + @pytest.mark.parametrize( + "value", + [ + "test", + 10, + 10, + 3.12, + ["one", "two"], + True, + False, + ], + ) + def test_no_type(self, unmarshallers_factory, value): + schema = {} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "type,value", + [ + ("string", "test"), + ("integer", 10), + ("number", 10), + ("number", 3.12), + ("array", ["one", "two"]), + ("boolean", True), + ("boolean", False), + ], + ) + def test_basic_types(self, unmarshallers_factory, type, value): + schema = { + "type": type, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "type,value", + [ + ("string", 10), + ("string", 3.14), + ("string", True), + ("string", ["one", "two"]), + ("string", {"one": "two"}), + ("integer", 3.14), + ("integer", True), + ("integer", ""), + ("integer", "test"), + ("integer", b"test"), + ("integer", ["one", "two"]), + ("integer", {"one": "two"}), + ("number", True), + ("number", ""), + ("number", "test"), + ("number", b"test"), + ("number", ["one", "two"]), + ("number", {"one": "two"}), + ("array", 10), + ("array", 3.14), + ("array", True), + ("array", ""), + ("array", "test"), + ("array", b"test"), + ("array", {"one": "two"}), + ("boolean", 10), + ("boolean", 3.14), + ("boolean", ""), + ("boolean", "test"), + ("boolean", b"test"), + ("boolean", ["one", "two"]), + ("boolean", {"one": "two"}), + ("object", 10), + ("object", 3.14), + ("object", True), + ("object", ""), + ("object", "test"), + ("object", b"test"), + ("object", ["one", "two"]), + ], + ) + def test_basic_types_invalid(self, unmarshallers_factory, type, value): + schema = { + "type": type, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises( + InvalidSchemaValue, + match=f"not valid for schema of type {type}", + ) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"is not of type '{type}'" + in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize( + "format,value,unmarshalled", + [ + ("int32", 13, 13), + ("int64", 13, 13), + ("float", 3.14, 3.14), + ("double", 3.14, 3.14), + ("password", "passwd", "passwd"), + ("date", "2018-12-13", date(2018, 12, 13)), + ( + "date-time", + "2018-12-13T13:34:59Z", + datetime(2018, 12, 13, 13, 34, 59, tzinfo=UTC), + ), + ( + "date-time", + "2018-12-13T13:34:59+02:00", + datetime(2018, 12, 13, 13, 34, 59, tzinfo=FixedOffset(2)), + ), + ( + "uuid", + "20a53f2e-0049-463d-b2b4-3fbbbb4cd8a7", + UUID("20a53f2e-0049-463d-b2b4-3fbbbb4cd8a7"), + ), + ], + ) + def test_basic_formats( + self, unmarshallers_factory, format, value, unmarshalled + ): + schema = { + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == unmarshalled + + @pytest.mark.parametrize( + "type,format,value,unmarshalled", + [ + ("integer", "int32", 13, 13), + ("integer", "int64", 13, 13), + ("number", "float", 3.14, 3.14), + ("number", "double", 3.14, 3.14), + ("string", "password", "passwd", "passwd"), + ("string", "date", "2018-12-13", date(2018, 12, 13)), + ( + "string", + "date-time", + "2018-12-13T13:34:59Z", + datetime(2018, 12, 13, 13, 34, 59, tzinfo=UTC), + ), + ( + "string", + "date-time", + "2018-12-13T13:34:59+02:00", + datetime(2018, 12, 13, 13, 34, 59, tzinfo=FixedOffset(2)), + ), + ( + "string", + "uuid", + "20a53f2e-0049-463d-b2b4-3fbbbb4cd8a7", + UUID("20a53f2e-0049-463d-b2b4-3fbbbb4cd8a7"), + ), + ], + ) + def test_basic_type_formats( + self, unmarshallers_factory, type, format, value, unmarshalled + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == unmarshalled + + @pytest.mark.parametrize( + "type,format,value", + [ + ("string", "float", "test"), + ("string", "double", "test"), + ("number", "date", 3), + ("number", "date-time", 3), + ("number", "uuid", 3), + ], + ) + def test_basic_type_formats_ignored( + self, unmarshallers_factory, type, format, value + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "type,format,value", + [ + ("string", "date", "test"), + ("string", "date-time", "test"), + ("string", "uuid", "test"), + ], + ) + def test_basic_type_formats_invalid( + self, unmarshallers_factory, type, format, value + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"is not a '{format}'" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize( + "value,expected", + [ + ("dGVzdA==", "test"), + ], + ) + def test_string_byte(self, unmarshallers_factory, value, expected): + schema = { + "type": "string", + "format": "byte", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == expected + + def test_string_date(self, unmarshallers_factory): + schema = { + "type": "string", + "format": "date", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02" + + result = unmarshaller.unmarshal(value) + + assert result == date(2018, 1, 2) + + @pytest.mark.parametrize( + "value,expected", + [ + ("2018-01-02T00:00:00Z", datetime(2018, 1, 2, 0, 0, tzinfo=UTC)), + ( + "2020-04-01T12:00:00+02:00", + datetime(2020, 4, 1, 12, 0, 0, tzinfo=FixedOffset(2)), + ), + ], + ) + def test_string_datetime(self, unmarshallers_factory, value, expected): + schema = { + "type": "string", + "format": "date-time", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == expected + + def test_string_datetime_invalid(self, unmarshallers_factory): + schema = { + "type": "string", + "format": "date-time", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02T00:00:00" + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + "is not a 'date-time'" in exc_info.value.schema_errors[0].message + ) + + def test_string_password(self, unmarshallers_factory): + schema = { + "type": "string", + "format": "password", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "passwd" + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_string_uuid(self, unmarshallers_factory): + schema = { + "type": "string", + "format": "uuid", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = str(uuid4()) + + result = unmarshaller.unmarshal(value) + + assert result == UUID(value) + + def test_string_uuid_invalid(self, unmarshallers_factory): + schema = { + "type": "string", + "format": "uuid", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "test" + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert "is not a 'uuid'" in exc_info.value.schema_errors[0].message + + @pytest.mark.parametrize( + "type,format,value,expected", + [ + ("string", "float", "test", "test"), + ("string", "double", "test", "test"), + ("integer", "byte", 10, 10), + ("integer", "date", 10, 10), + ("integer", "date-time", 10, 10), + ("string", "int32", "test", "test"), + ("string", "int64", "test", "test"), + ("integer", "password", 10, 10), + ], + ) + def test_formats_ignored( + self, unmarshallers_factory, type, format, value, expected + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == expected + + @pytest.mark.parametrize("value", ["bar", "foobar"]) + def test_string_pattern(self, unmarshallers_factory, value): + schema = { + "type": "string", + "pattern": "bar", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value,pattern", + [ + ("foo", "baz"), + ("bar", "baz"), + ], + ) + def test_string_pattern_invalid( + self, unmarshallers_factory, value, pattern + ): + schema = { + "type": "string", + "pattern": pattern, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"'{value}' does not match '{pattern}'" + in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize("value", ["abc", "abcd"]) + def test_string_min_length(self, unmarshallers_factory, value): + schema = { + "type": "string", + "minLength": 3, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize("value", ["", "a", "ab"]) + def test_string_min_length_invalid(self, unmarshallers_factory, value): + schema = { + "type": "string", + "minLength": 3, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"'{value}' is too short" + in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize("value", ["", "a"]) + def test_string_max_length(self, unmarshallers_factory, value): + schema = { + "type": "string", + "maxLength": 1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize("value", ["ab", "abc"]) + def test_string_max_length_invalid(self, unmarshallers_factory, value): + schema = { + "type": "string", + "maxLength": 1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"'{value}' is too long" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize( + "value", + [ + "", + ], + ) + def test_string_max_length_invalid_schema( + self, unmarshallers_factory, value + ): + schema = { + "type": "string", + "maxLength": -1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + def test_integer_enum(self, unmarshallers_factory): + schema = { + "type": "integer", + "enum": [1, 2, 3], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = 2 + + result = unmarshaller.unmarshal(value) + + assert result == int(value) + + def test_integer_enum_invalid(self, unmarshallers_factory): + enum = [1, 2, 3] + schema = { + "type": "integer", + "enum": enum, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = 12 + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"{value} is not one of {enum}" + in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize( + "type,value", + [ + ("string", "test"), + ("integer", 10), + ("number", 10), + ("number", 3.12), + ("array", ["one", "two"]), + ("boolean", True), + ("boolean", False), + ], + ) + def test_array(self, unmarshallers_factory, type, value): + schema = { + "type": "array", + "items": { + "type": type, + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value_list = [value] * 3 + + result = unmarshaller.unmarshal(value_list) + + assert result == value_list + + @pytest.mark.parametrize( + "type,value", + [ + ("integer", True), + ("integer", "123"), + ("string", 123), + ("string", True), + ("boolean", 123), + ("boolean", "123"), + ], + ) + def test_array_invalid(self, unmarshallers_factory, type, value): + schema = { + "type": "array", + "items": { + "type": type, + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal([value]) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"is not of type '{type}'" + in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize("value", [[], [1], [1, 2]]) + def test_array_min_items_invalid(self, unmarshallers_factory, value): + schema = { + "type": "array", + "items": { + "type": "number", + }, + "minItems": 3, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"{value} is too short" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize("value", [[], [1], [1, 2]]) + def test_array_min_items(self, unmarshallers_factory, value): + schema = { + "type": "array", + "items": { + "type": "number", + }, + "minItems": 0, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + [], + ], + ) + def test_array_max_items_invalid_schema( + self, unmarshallers_factory, value + ): + schema = { + "type": "array", + "items": { + "type": "number", + }, + "maxItems": -1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize("value", [[1, 2], [2, 3, 4]]) + def test_array_max_items_invalid(self, unmarshallers_factory, value): + schema = { + "type": "array", + "items": { + "type": "number", + }, + "maxItems": 1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"{value} is too long" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize("value", [[1, 2, 1], [2, 2]]) + def test_array_unique_items_invalid(self, unmarshallers_factory, value): + schema = { + "type": "array", + "items": { + "type": "number", + }, + "uniqueItems": True, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"{value} has non-unique elements" + in exc_info.value.schema_errors[0].message + ) + + def test_object_any_of(self, unmarshallers_factory): + schema = { + "type": "object", + "anyOf": [ + { + "type": "object", + "required": ["someint"], + "properties": {"someint": {"type": "integer"}}, + }, + { + "type": "object", + "required": ["somestr"], + "properties": {"somestr": {"type": "string"}}, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = {"someint": 1} + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_object_any_of_invalid(self, unmarshallers_factory): + schema = { + "type": "object", + "anyOf": [ + { + "type": "object", + "required": ["someint"], + "properties": {"someint": {"type": "integer"}}, + }, + { + "type": "object", + "required": ["somestr"], + "properties": {"somestr": {"type": "string"}}, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal({"someint": "1"}) + + def test_object_one_of_default(self, unmarshallers_factory): + schema = { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "somestr": { + "type": "string", + "default": "defaultstring", + }, + }, + }, + { + "type": "object", + "required": ["otherstr"], + "properties": { + "otherstr": { + "type": "string", + }, + }, + }, + ], + "properties": { + "someint": { + "type": "integer", + }, + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + assert unmarshaller.unmarshal({"someint": 1}) == { + "someint": 1, + "somestr": "defaultstring", + } + + def test_object_any_of_default(self, unmarshallers_factory): + schema = { + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "someint": { + "type": "integer", + }, + }, + }, + { + "type": "object", + "properties": { + "somestr": { + "type": "string", + "default": "defaultstring", + }, + }, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + assert unmarshaller.unmarshal({"someint": "1"}) == { + "someint": "1", + "somestr": "defaultstring", + } + + def test_object_all_of_default(self, unmarshallers_factory): + schema = { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "somestr": { + "type": "string", + "default": "defaultstring", + }, + }, + }, + { + "type": "object", + "properties": { + "someint": { + "type": "integer", + "default": 1, + }, + }, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + assert unmarshaller.unmarshal({}) == { + "someint": 1, + "somestr": "defaultstring", + } + + @pytest.mark.parametrize( + "value", + [ + { + "someint": 123, + }, + { + "somestr": "content", + }, + { + "somestr": "content", + "someint": 123, + }, + ], + ) + def test_object_with_properties(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": { + "somestr": { + "type": "string", + }, + "someint": { + "type": "integer", + }, + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + { + "somestr": {}, + "someint": 123, + }, + { + "somestr": ["content1", "content2"], + "someint": 123, + }, + { + "somestr": 123, + "someint": 123, + }, + { + "somestr": "content", + "someint": 123, + "not_in_scheme_prop": 123, + }, + ], + ) + def test_object_with_properties_invalid( + self, unmarshallers_factory, value + ): + schema = { + "type": "object", + "properties": { + "somestr": { + "type": "string", + }, + "someint": { + "type": "integer", + }, + }, + "additionalProperties": False, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_object_default_property(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": { + "prop": { + "type": "string", + "default": "value1", + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == {"prop": "value1"} + + @pytest.mark.parametrize( + "value", + [ + {"additional": 1}, + ], + ) + def test_object_additional_properties_false( + self, unmarshallers_factory, value + ): + schema = { + "type": "object", + "additionalProperties": False, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {"additional": 1}, + {"foo": "bar", "bar": "foo"}, + {"additional": {"bar": 1}}, + ], + ) + @pytest.mark.parametrize("additional_properties", [True, {}]) + def test_object_additional_properties_free_form_object( + self, value, additional_properties, unmarshallers_factory + ): + schema = { + "type": "object", + "additionalProperties": additional_properties, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_object_additional_properties_list(self, unmarshallers_factory): + schema = {"type": "object"} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal({"user_ids": [1, 2, 3, 4]}) + + assert result == { + "user_ids": [1, 2, 3, 4], + } + + @pytest.mark.parametrize( + "value", + [ + {"additional": 1}, + ], + ) + def test_object_additional_properties(self, unmarshallers_factory, value): + schema = { + "type": "object", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + {"additional": 1}, + ], + ) + def test_object_additional_properties_object( + self, unmarshallers_factory, value + ): + additional_properties = { + "type": "integer", + } + schema = { + "type": "object", + "additionalProperties": additional_properties, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + {"a": 1}, + {"a": 1, "b": 2}, + {"a": 1, "b": 2, "c": 3}, + ], + ) + def test_object_min_properties(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": {k: {"type": "number"} for k in ["a", "b", "c"]}, + "minProperties": 1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + {"a": 1}, + {"a": 1, "b": 2}, + {"a": 1, "b": 2, "c": 3}, + ], + ) + def test_object_min_properties_invalid(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": {k: {"type": "number"} for k in ["a", "b", "c"]}, + "minProperties": 4, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_object_min_properties_invalid_schema( + self, unmarshallers_factory, value + ): + schema = { + "type": "object", + "minProperties": 2, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {"a": 1}, + {"a": 1, "b": 2}, + {"a": 1, "b": 2, "c": 3}, + ], + ) + def test_object_max_properties(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": {k: {"type": "number"} for k in ["a", "b", "c"]}, + "maxProperties": 3, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + {"a": 1}, + {"a": 1, "b": 2}, + {"a": 1, "b": 2, "c": 3}, + ], + ) + def test_object_max_properties_invalid(self, unmarshallers_factory, value): + schema = { + "type": "object", + "properties": {k: {"type": "number"} for k in ["a", "b", "c"]}, + "maxProperties": 0, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_object_max_properties_invalid_schema( + self, unmarshallers_factory, value + ): + schema = { + "type": "object", + "maxProperties": -1, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + def test_any_one_of(self, unmarshallers_factory): + schema = { + "oneOf": [ + { + "type": "string", + }, + { + "type": "array", + "items": { + "type": "string", + }, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = ["hello"] + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_any_any_of(self, unmarshallers_factory): + schema = { + "anyOf": [ + { + "type": "string", + }, + { + "type": "array", + "items": { + "type": "string", + }, + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = ["hello"] + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_any_all_of(self, unmarshallers_factory): + schema = { + "allOf": [ + { + "type": "array", + "items": { + "type": "string", + }, + } + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = ["hello"] + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + { + "somestr": {}, + "someint": 123, + }, + { + "somestr": ["content1", "content2"], + "someint": 123, + }, + { + "somestr": 123, + "someint": 123, + }, + { + "somestr": "content", + "someint": 123, + "not_in_scheme_prop": 123, + }, + ], + ) + def test_any_all_of_invalid_properties(self, value, unmarshallers_factory): + schema = { + "allOf": [ + { + "type": "object", + "required": ["somestr"], + "properties": { + "somestr": { + "type": "string", + }, + }, + }, + { + "type": "object", + "required": ["someint"], + "properties": { + "someint": { + "type": "integer", + }, + }, + }, + ], + "additionalProperties": False, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + def test_any_format_one_of(self, unmarshallers_factory): + schema = { + "format": "date", + "oneOf": [ + {"type": "integer"}, + { + "type": "string", + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02" + + result = unmarshaller.unmarshal(value) + + assert result == date(2018, 1, 2) + + def test_any_one_of_any(self, unmarshallers_factory): + schema = { + "oneOf": [ + {"type": "integer"}, + { + "type": "string", + "format": "date", + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02" + + result = unmarshaller.unmarshal(value) + + assert result == date(2018, 1, 2) + + def test_any_any_of_any(self, unmarshallers_factory): + schema = { + "anyOf": [ + {}, + { + "type": "string", + "format": "date", + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02" + + result = unmarshaller.unmarshal(value) + + assert result == date(2018, 1, 2) + + def test_any_all_of_any(self, unmarshallers_factory): + schema = { + "allOf": [ + {}, + { + "type": "string", + "format": "date", + }, + ], + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = "2018-01-02" + + result = unmarshaller.unmarshal(value) + + assert result == date(2018, 1, 2) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_any_of_no_valid(self, unmarshallers_factory, value): + any_of = [ + { + "type": "object", + "required": ["test1"], + "properties": { + "test1": { + "type": "string", + }, + }, + }, + { + "type": "object", + "required": ["test2"], + "properties": { + "test2": { + "type": "string", + }, + }, + }, + ] + schema = { + "anyOf": any_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_any_one_of_no_valid(self, unmarshallers_factory, value): + one_of = [ + { + "type": "object", + "required": [ + "test1", + ], + "properties": { + "test1": { + "type": "string", + }, + }, + }, + { + "type": "object", + "required": [ + "test2", + ], + "properties": { + "test2": { + "type": "string", + }, + }, + }, + ] + schema = { + "oneOf": one_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_any_any_of_different_type(self, unmarshallers_factory, value): + any_of = [{"type": "integer"}, {"type": "string"}] + schema = { + "anyOf": any_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_any_one_of_different_type(self, unmarshallers_factory, value): + one_of = [ + { + "type": "integer", + }, + { + "type": "string", + }, + ] + schema = { + "oneOf": one_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + { + "foo": "FOO", + }, + { + "foo": "FOO", + "bar": "BAR", + }, + ], + ) + def test_any_any_of_unambiguous(self, unmarshallers_factory, value): + any_of = [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string", + }, + }, + "additionalProperties": False, + }, + { + "type": "object", + "required": ["foo", "bar"], + "properties": { + "foo": { + "type": "string", + }, + "bar": { + "type": "string", + }, + }, + "additionalProperties": False, + }, + ] + schema = { + "anyOf": any_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + {}, + ], + ) + def test_object_multiple_any_of(self, unmarshallers_factory, value): + any_of = [ + { + "type": "object", + }, + { + "type": "object", + }, + ] + schema = { + "type": "object", + "anyOf": any_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "value", + [ + dict(), + ], + ) + def test_object_multiple_one_of(self, unmarshallers_factory, value): + one_of = [ + { + "type": "object", + }, + { + "type": "object", + }, + ] + schema = { + "type": "object", + "oneOf": one_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + @pytest.mark.parametrize( + "value", + [ + { + "foo": "FOO", + }, + { + "foo": "FOO", + "bar": "BAR", + }, + ], + ) + def test_any_one_of_unambiguous(self, unmarshallers_factory, value): + one_of = [ + { + "type": "object", + "required": [ + "foo", + ], + "properties": { + "foo": { + "type": "string", + }, + }, + "additionalProperties": False, + }, + { + "type": "object", + "required": ["foo", "bar"], + "properties": { + "foo": { + "type": "string", + }, + "bar": { + "type": "string", + }, + }, + "additionalProperties": False, + }, + ] + schema = { + "oneOf": one_of, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + +class BaseTestOASS30chemaUnmarshallersFactoryCall: + def test_null_undefined(self, unmarshallers_factory): + schema = {"type": "null"} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(UnknownType): + unmarshaller.unmarshal(None) + + @pytest.mark.parametrize( + "type", + [ + "boolean", + "array", + "integer", + "number", + "string", + ], + ) + def test_nullable(self, unmarshallers_factory, type): + schema = {"type": type, "nullable": True} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(None) + + assert result is None + + @pytest.mark.parametrize( + "type", + [ + "boolean", + "array", + "integer", + "number", + "string", + ], + ) + def test_not_nullable(self, unmarshallers_factory, type): + schema = {"type": type} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises( + InvalidSchemaValue, + match=f"not valid for schema of type {type}", + ) as exc_info: + unmarshaller.unmarshal(None) + assert len(exc_info.value.schema_errors) == 2 + assert ( + "None for not nullable" in exc_info.value.schema_errors[0].message + ) + assert ( + f"None is not of type '{type}'" + in exc_info.value.schema_errors[1].message + ) + + @pytest.mark.parametrize( + "type,format,value,unmarshalled", + [ + ("string", "byte", "dGVzdA==", "test"), + ("string", "binary", b"test", b"test"), + ], + ) + def test_basic_type_oas30_formats( + self, unmarshallers_factory, type, format, value, unmarshalled + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == unmarshalled + + @pytest.mark.parametrize( + "type,format,value", + [ + ("string", "byte", "passwd"), + ("string", "binary", "test"), + ], + ) + def test_basic_type_oas30_formats_invalid( + self, unmarshallers_factory, type, format, value + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises( + InvalidSchemaValue, + match=f"not valid for schema of type {type}", + ) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + f"is not a '{format}'" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.xfail( + reason=( + "OAS 3.0 string type checker allows byte. " + "See https://github.com/python-openapi/openapi-schema-validator/issues/64" + ), + strict=True, + ) + def test_string_format_binary_invalid(self, unmarshallers_factory): + schema = { + "type": "string", + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = b"true" + + with pytest.raises( + InvalidSchemaValue, + match=f"not valid for schema of type {type}", + ): + unmarshaller.unmarshal(value) + + @pytest.mark.xfail( + reason=( + "Rraises TypeError not SchemaError. " + "See ttps://github.com/python-openapi/openapi-schema-validator/issues/65" + ), + strict=True, + ) + @pytest.mark.parametrize( + "types,value", + [ + (["string", "null"], "string"), + (["number", "null"], 2), + (["number", "null"], 3.14), + (["boolean", "null"], True), + (["array", "null"], [1, 2]), + (["object", "null"], {}), + ], + ) + def test_nultiple_types_undefined( + self, unmarshallers_factory, types, value + ): + schema = {"type": types} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(SchemaError): + unmarshaller.unmarshal(value) + + def test_integer_default_nullable(self, unmarshallers_factory): + default_value = 123 + schema = { + "type": "integer", + "default": default_value, + "nullable": True, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = None + + result = unmarshaller.unmarshal(value) + + assert result is None + + def test_array_nullable(self, unmarshallers_factory): + schema = { + "type": "array", + "items": { + "type": "integer", + }, + "nullable": True, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = None + + result = unmarshaller.unmarshal(value) + + assert result is None + + def test_object_property_nullable(self, unmarshallers_factory): + schema = { + "type": "object", + "properties": { + "foo": { + "type": "object", + "nullable": True, + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = {"foo": None} + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_subschema_nullable(self, unmarshallers_factory): + schema = { + "oneOf": [ + { + "type": "integer", + }, + { + "nullable": True, + }, + ] + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = None + + result = unmarshaller.unmarshal(value) + + assert result is None + + +class TestOAS30RequestSchemaUnmarshallersFactory( + BaseTestOASSchemaUnmarshallersFactoryCall, + BaseTestOASS30chemaUnmarshallersFactoryCall, +): + @pytest.fixture + def unmarshallers_factory(self): + return oas30_write_schema_unmarshallers_factory + + def test_write_only_properties(self, unmarshallers_factory): + schema = { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "writeOnly": True, + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = {"id": 10} + + # readOnly properties may be admitted in a Response context + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_read_only_properties_invalid(self, unmarshallers_factory): + schema = { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "readOnly": True, + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = {"id": 10} + + # readOnly properties are not admitted on a Request context + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + +class TestOAS30ResponseSchemaUnmarshallersFactory( + BaseTestOASSchemaUnmarshallersFactoryCall, + BaseTestOASS30chemaUnmarshallersFactoryCall, +): + @pytest.fixture + def unmarshallers_factory(self): + return oas30_read_schema_unmarshallers_factory + + def test_read_only_properties(self, unmarshallers_factory): + schema = { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "readOnly": True, + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + # readOnly properties may be admitted in a Response context + result = unmarshaller.unmarshal({"id": 10}) + + assert result == { + "id": 10, + } + + def test_write_only_properties_invalid(self, unmarshallers_factory): + schema = { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "integer", + "writeOnly": True, + } + }, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + # readOnly properties are not admitted on a Request context + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal({"id": 10}) + + +class TestOAS31SchemaUnmarshallersFactory( + BaseTestOASSchemaUnmarshallersFactoryCall +): + @pytest.fixture + def unmarshallers_factory(self): + return oas31_schema_unmarshallers_factory + + @pytest.mark.xfail( + reason=( + "OpenAPI 3.1 schema validator uses OpenAPI 3.0 format checker." + "See https://github.com/python-openapi/openapi-core/issues/506" + ), + strict=True, + ) + @pytest.mark.parametrize( + "type,format", + [ + ("string", "byte"), + ("string", "binary"), + ], + ) + def test_create_oas30_formatter_not_found( + self, unmarshallers_factory, type, format + ): + schema = { + "type": type, + "format": format, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(FormatterNotFoundError): + unmarshallers_factory.create(spec) + + @pytest.mark.parametrize( + "type,value", + [ + ("string", b"test"), + ("integer", b"test"), + ("number", b"test"), + ("array", b"test"), + ("boolean", b"test"), + ("object", b"test"), + ], + ) + def test_basic_types_invalid(self, unmarshallers_factory, type, value): + schema = { + "type": type, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises( + InvalidSchemaValue, + match=f"not valid for schema of type {type}", + ): + unmarshaller.unmarshal(value) + + def test_null(self, unmarshallers_factory): + schema = {"type": "null"} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(None) + + assert result is None + + @pytest.mark.parametrize("value", ["string", 2, 3.14, True, [1, 2], {}]) + def test_null_invalid(self, unmarshallers_factory, value): + schema = {"type": "null"} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert ( + "is not of type 'null'" in exc_info.value.schema_errors[0].message + ) + + @pytest.mark.parametrize( + "types,value", + [ + (["string", "null"], "string"), + (["number", "null"], 2), + (["number", "null"], 3.14), + (["boolean", "null"], True), + (["array", "null"], [1, 2]), + (["object", "null"], {}), + ], + ) + def test_nultiple_types(self, unmarshallers_factory, types, value): + schema = {"type": types} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.parametrize( + "types,value", + [ + (["string", "null"], 2), + (["number", "null"], "string"), + (["number", "null"], True), + (["boolean", "null"], 3.14), + (["array", "null"], {}), + (["object", "null"], [1, 2]), + ], + ) + def test_nultiple_types_invalid(self, unmarshallers_factory, types, value): + schema = {"type": types} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + with pytest.raises(InvalidSchemaValue) as exc_info: + unmarshaller.unmarshal(value) + assert len(exc_info.value.schema_errors) == 1 + assert "is not of type" in exc_info.value.schema_errors[0].message + + @pytest.mark.parametrize( + "types,format,value,expected", + [ + (["string", "null"], "date", None, None), + (["string", "null"], "date", "2018-12-13", date(2018, 12, 13)), + ], + ) + def test_multiple_types_format_valid_or_ignored( + self, unmarshallers_factory, types, format, value, expected + ): + schema = { + "type": types, + "format": format, + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(value) + + assert result == expected + + def test_any_null(self, unmarshallers_factory): + schema = {} + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + + result = unmarshaller.unmarshal(None) + + assert result is None + + def test_subschema_null(self, unmarshallers_factory): + schema = { + "oneOf": [ + { + "type": "integer", + }, + { + "type": "null", + }, + ] + } + spec = SchemaPath.from_dict(schema) + unmarshaller = unmarshallers_factory.create(spec) + value = None + + result = unmarshaller.unmarshal(value) + + assert result is None diff --git a/tests/integration/validation/test_minimal.py b/tests/integration/validation/test_minimal.py deleted file mode 100644 index 6936ce17..00000000 --- a/tests/integration/validation/test_minimal.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest - -from openapi_core.shortcuts import create_spec -from openapi_core.templating.paths.exceptions import ( - PathNotFound, OperationNotFound, -) -from openapi_core.testing import MockRequest -from openapi_core.validation.request.datatypes import RequestParameters -from openapi_core.validation.request.validators import RequestValidator - - -class TestMinimal(object): - - servers = [ - "http://minimal.test/", - "https://bad.remote.domain.net/", - "http://localhost", - "http://localhost:8080", - "https://u:p@a.b:1337" - ] - - spec_paths = [ - "data/v3.0/minimal_with_servers.yaml", - "data/v3.0/minimal.yaml" - ] - - @pytest.mark.parametrize("server", servers) - @pytest.mark.parametrize("spec_path", spec_paths) - def test_hosts(self, factory, server, spec_path): - spec_dict = factory.spec_from_file(spec_path) - spec = create_spec(spec_dict) - validator = RequestValidator(spec) - request = MockRequest(server, "get", "/status") - - result = validator.validate(request) - - assert not result.errors - - @pytest.mark.parametrize("server", servers) - @pytest.mark.parametrize("spec_path", spec_paths) - def test_invalid_operation(self, factory, server, spec_path): - spec_dict = factory.spec_from_file(spec_path) - spec = create_spec(spec_dict) - validator = RequestValidator(spec) - request = MockRequest(server, "post", "/status") - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert isinstance(result.errors[0], OperationNotFound) - assert result.body is None - assert result.parameters == RequestParameters() - - @pytest.mark.parametrize("server", servers) - @pytest.mark.parametrize("spec_path", spec_paths) - def test_invalid_path(self, factory, server, spec_path): - spec_dict = factory.spec_from_file(spec_path) - spec = create_spec(spec_dict) - validator = RequestValidator(spec) - request = MockRequest(server, "get", "/nonexistent") - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert isinstance(result.errors[0], PathNotFound) - assert result.body is None - assert result.parameters == RequestParameters() diff --git a/tests/integration/validation/test_parent_reference.py b/tests/integration/validation/test_parent_reference.py new file mode 100644 index 00000000..21e37351 --- /dev/null +++ b/tests/integration/validation/test_parent_reference.py @@ -0,0 +1,45 @@ +import json + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core import Config +from openapi_core import OpenAPI +from openapi_core import V30ResponseUnmarshaller +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse + + +class TestParentReference: + + spec_path = "data/v3.0/parent-reference/openapi.yaml" + + @pytest.fixture + def unmarshaller(self, content_factory): + content, base_uri = content_factory.from_file(self.spec_path) + return V30ResponseUnmarshaller( + spec=SchemaPath.from_dict(content, base_uri=base_uri) + ) + + @pytest.fixture + def openapi(self, content_factory): + content, base_uri = content_factory.from_file(self.spec_path) + spec = SchemaPath.from_dict(content, base_uri=base_uri) + config = Config(spec_base_uri=base_uri) + return OpenAPI(spec, config=config) + + def test_valid(self, openapi): + request = MockRequest(host_url="", method="GET", path="/books") + response = MockResponse( + data=json.dumps([{"id": "BOOK:01", "title": "Test Book"}]).encode() + ) + + openapi.validate_response(request, response) + + def test_unmarshal(self, unmarshaller): + request = MockRequest(host_url="", method="GET", path="/books") + response = MockResponse( + data=json.dumps([{"id": "BOOK:01", "title": "Test Book"}]).encode() + ) + + unmarshaller.unmarshal(request, response) diff --git a/tests/integration/validation/test_petstore.py b/tests/integration/validation/test_petstore.py deleted file mode 100644 index c483c03e..00000000 --- a/tests/integration/validation/test_petstore.py +++ /dev/null @@ -1,1173 +0,0 @@ -import json -import pytest -from datetime import datetime -from base64 import b64encode -from uuid import UUID -from isodate.tzinfo import UTC -from six import text_type - -from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.deserializing.parameters.exceptions import ( - EmptyParameterValue, -) -from openapi_core.extensions.models.models import BaseModel -from openapi_core.schema.media_types.exceptions import InvalidContentType -from openapi_core.schema.parameters.exceptions import ( - MissingRequiredParameter, -) -from openapi_core.schema.schemas.enums import SchemaType -from openapi_core.shortcuts import ( - create_spec, validate_parameters, validate_body, validate_data, -) -from openapi_core.templating.paths.exceptions import ( - ServerNotFound, -) -from openapi_core.testing import MockRequest, MockResponse -from openapi_core.unmarshalling.schemas.exceptions import InvalidSchemaValue -from openapi_core.validation.request.datatypes import RequestParameters -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -class TestPetstore(object): - - api_key = '12345' - - @property - def api_key_encoded(self): - api_key_bytes = self.api_key.encode('utf8') - api_key_bytes_enc = b64encode(api_key_bytes) - return text_type(api_key_bytes_enc, 'utf8') - - @pytest.fixture - def spec_uri(self): - return "file://tests/integration/data/v3.0/petstore.yaml" - - @pytest.fixture - def spec_dict(self, factory): - return factory.spec_from_file("data/v3.0/petstore.yaml") - - @pytest.fixture - def spec(self, spec_dict, spec_uri): - return create_spec(spec_dict, spec_uri) - - @pytest.fixture - def request_validator(self, spec): - return RequestValidator(spec) - - @pytest.fixture - def response_validator(self, spec): - return ResponseValidator(spec) - - def test_get_pets(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': '20', - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': 20, - 'page': 1, - 'search': '', - } - ) - assert body is None - - data_json = { - 'data': [], - } - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.data == [] - - def test_get_pets_response(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': '20', - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': 20, - 'page': 1, - 'search': '', - } - ) - assert body is None - - data_json = { - 'data': [ - { - 'id': 1, - 'name': 'Cat', - 'ears': { - 'healthy': True, - }, - } - ], - } - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert len(response_result.data.data) == 1 - assert response_result.data.data[0].id == 1 - assert response_result.data.data[0].name == 'Cat' - - def test_get_pets_invalid_response(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': '20', - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': 20, - 'page': 1, - 'search': '', - } - ) - assert body is None - - response_data_json = { - 'data': [ - { - 'id': 1, - 'name': { - 'first_name': 'Cat', - }, - } - ], - } - response_data = json.dumps(response_data_json) - response = MockResponse(response_data) - - with pytest.raises(InvalidSchemaValue): - validate_data(spec, request, response) - - response_result = response_validator.validate(request, response) - - schema_errors = response_result.errors[0].schema_errors - assert response_result.errors == [ - InvalidSchemaValue( - type=SchemaType.OBJECT, - value=response_data_json, - schema_errors=schema_errors, - ), - ] - assert response_result.data is None - - def test_get_pets_ids_param(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': '20', - 'ids': ['12', '13'], - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': 20, - 'page': 1, - 'search': '', - 'ids': [12, 13], - } - ) - assert body is None - - data_json = { - 'data': [], - } - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.data == [] - - def test_get_pets_tags_param(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = [ - ('limit', '20'), - ('tags', 'cats,dogs'), - ] - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': 20, - 'page': 1, - 'search': '', - 'tags': ['cats', 'dogs'], - } - ) - assert body is None - - data_json = { - 'data': [], - } - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.data == [] - - def test_get_pets_parameter_deserialization_error(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': 1, - 'tags': 12, - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - with pytest.raises(DeserializeError): - validate_parameters(spec, request) - - body = validate_body(spec, request) - - assert body is None - - def test_get_pets_wrong_parameter_type(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': 'twenty', - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - with pytest.raises(CastError): - validate_parameters(spec, request) - - body = validate_body(spec, request) - - assert body is None - - def test_get_pets_raises_missing_required_param(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, - ) - - with pytest.raises(MissingRequiredParameter): - validate_parameters(spec, request) - - body = validate_body(spec, request) - - assert body is None - - def test_get_pets_empty_value(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': '', - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - with pytest.raises(EmptyParameterValue): - validate_parameters(spec, request) - body = validate_body(spec, request) - - assert body is None - - def test_get_pets_none_value(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets' - query_params = { - 'limit': None, - } - - request = MockRequest( - host_url, 'GET', '/pets', - path_pattern=path_pattern, args=query_params, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - query={ - 'limit': None, - 'page': 1, - 'search': '', - } - ) - - body = validate_body(spec, request) - - assert body is None - - def test_post_birds(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_tag = 'cats' - pet_street = 'Piekna' - pet_city = 'Warsaw' - pet_healthy = False - data_json = { - 'name': pet_name, - 'tag': pet_tag, - 'position': 2, - 'address': { - 'street': pet_street, - 'city': pet_city, - }, - 'healthy': pet_healthy, - 'wings': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - address_model = schemas['Address']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert body.tag == pet_tag - assert body.position == 2 - assert body.address.__class__.__name__ == address_model - assert body.address.street == pet_street - assert body.address.city == pet_city - assert body.healthy == pet_healthy - - def test_post_cats(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_tag = 'cats' - pet_street = 'Piekna' - pet_city = 'Warsaw' - pet_healthy = False - data_json = { - 'name': pet_name, - 'tag': pet_tag, - 'position': 2, - 'address': { - 'street': pet_street, - 'city': pet_city, - }, - 'healthy': pet_healthy, - 'ears': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - address_model = schemas['Address']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert body.tag == pet_tag - assert body.position == 2 - assert body.address.__class__.__name__ == address_model - assert body.address.street == pet_street - assert body.address.city == pet_city - assert body.healthy == pet_healthy - - def test_post_cats_boolean_string(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_tag = 'cats' - pet_street = 'Piekna' - pet_city = 'Warsaw' - pet_healthy = False - data_json = { - 'name': pet_name, - 'tag': pet_tag, - 'position': 2, - 'address': { - 'street': pet_street, - 'city': pet_city, - }, - 'healthy': pet_healthy, - 'ears': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - address_model = schemas['Address']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert body.tag == pet_tag - assert body.position == 2 - assert body.address.__class__.__name__ == address_model - assert body.address.street == pet_street - assert body.address.city == pet_city - assert body.healthy is False - - def test_post_no_one_of_schema(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - alias = 'kitty' - data_json = { - 'name': pet_name, - 'alias': alias, - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - with pytest.raises(InvalidSchemaValue): - validate_body(spec, request) - - def test_post_cats_only_required_body(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_healthy = True - data_json = { - 'name': pet_name, - 'ears': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert not hasattr(body, 'tag') - assert not hasattr(body, 'address') - - def test_post_pets_raises_invalid_mimetype(self, spec): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - data_json = { - 'name': 'Cat', - 'tag': 'cats', - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, mimetype='text/html', - headers=headers, cookies=cookies, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - with pytest.raises(InvalidContentType): - validate_body(spec, request) - - def test_post_pets_missing_cookie(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_healthy = True - data_json = { - 'name': pet_name, - 'ears': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - headers=headers, - ) - - with pytest.raises(MissingRequiredParameter): - validate_parameters(spec, request) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert not hasattr(body, 'tag') - assert not hasattr(body, 'address') - - def test_post_pets_missing_header(self, spec, spec_dict): - host_url = 'https://staging.gigantic-server.com/v1' - path_pattern = '/v1/pets' - pet_name = 'Cat' - pet_healthy = True - data_json = { - 'name': pet_name, - 'ears': { - 'healthy': pet_healthy, - } - } - data = json.dumps(data_json) - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, - cookies=cookies, - ) - - with pytest.raises(MissingRequiredParameter): - validate_parameters(spec, request) - - body = validate_body(spec, request) - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - assert body.__class__.__name__ == pet_model - assert body.name == pet_name - assert not hasattr(body, 'tag') - assert not hasattr(body, 'address') - - def test_post_pets_raises_invalid_server_error(self, spec): - host_url = 'http://flowerstore.swagger.io/v1' - path_pattern = '/v1/pets' - data_json = { - 'name': 'Cat', - 'tag': 'cats', - } - data = json.dumps(data_json) - headers = { - 'api_key': '12345', - } - cookies = { - 'user': '123', - } - - request = MockRequest( - host_url, 'POST', '/pets', - path_pattern=path_pattern, data=data, mimetype='text/html', - headers=headers, cookies=cookies, - ) - - with pytest.raises(ServerNotFound): - validate_parameters(spec, request) - - with pytest.raises(ServerNotFound): - validate_body(spec, request) - - data_id = 1 - data_name = 'test' - data_json = { - 'data': { - 'id': data_id, - 'name': data_name, - 'ears': { - 'healthy': True, - }, - }, - } - data = json.dumps(data_json) - response = MockResponse(data) - - with pytest.raises(ServerNotFound): - validate_data(spec, request, response) - - def test_get_pet(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets/{petId}' - view_args = { - 'petId': '1', - } - request = MockRequest( - host_url, 'GET', '/pets/1', - path_pattern=path_pattern, view_args=view_args, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - path={ - 'petId': 1, - } - ) - - body = validate_body(spec, request) - - assert body is None - - data_id = 1 - data_name = 'test' - data_json = { - 'data': { - 'id': data_id, - 'name': data_name, - 'ears': { - 'healthy': True, - }, - }, - } - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert isinstance(response_result.data.data, BaseModel) - assert response_result.data.data.id == data_id - assert response_result.data.data.name == data_name - - def test_get_pet_not_found(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets/{petId}' - view_args = { - 'petId': '1', - } - request = MockRequest( - host_url, 'GET', '/pets/1', - path_pattern=path_pattern, view_args=view_args, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - path={ - 'petId': 1, - } - ) - - body = validate_body(spec, request) - - assert body is None - - code = 404 - message = 'Not found' - rootCause = 'Pet not found' - data_json = { - 'code': 404, - 'message': message, - 'rootCause': rootCause, - } - data = json.dumps(data_json) - response = MockResponse(data, status_code=404) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.code == code - assert response_result.data.message == message - assert response_result.data.rootCause == rootCause - - def test_get_pet_wildcard(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/pets/{petId}' - view_args = { - 'petId': '1', - } - request = MockRequest( - host_url, 'GET', '/pets/1', - path_pattern=path_pattern, view_args=view_args, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters( - path={ - 'petId': 1, - } - ) - - body = validate_body(spec, request) - - assert body is None - - data = b'imagedata' - response = MockResponse(data, mimetype='image/png') - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert response_result.data == data - - def test_get_tags(self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - - request = MockRequest( - host_url, 'GET', '/tags', - path_pattern=path_pattern, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters() - assert body is None - - data_json = ['cats', 'birds'] - data = json.dumps(data_json) - response = MockResponse(data) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert response_result.data == data_json - - def test_post_tags_extra_body_properties(self, spec, spec_dict): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - pet_name = 'Dog' - alias = 'kitty' - data_json = { - 'name': pet_name, - 'alias': alias, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters() - - with pytest.raises(InvalidSchemaValue): - validate_body(spec, request) - - def test_post_tags_empty_body(self, spec, spec_dict): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - data_json = {} - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters() - - with pytest.raises(InvalidSchemaValue): - validate_body(spec, request) - - def test_post_tags_wrong_property_type(self, spec): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - tag_name = 123 - data = json.dumps(tag_name) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - - assert parameters == RequestParameters() - - with pytest.raises(InvalidSchemaValue): - validate_body(spec, request) - - def test_post_tags_additional_properties( - self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - pet_name = 'Dog' - data_json = { - 'name': pet_name, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters() - assert isinstance(body, BaseModel) - assert body.name == pet_name - - code = 400 - message = 'Bad request' - rootCause = 'Tag already exist' - additionalinfo = 'Tag Dog already exist' - data_json = { - 'code': code, - 'message': message, - 'rootCause': rootCause, - 'additionalinfo': additionalinfo, - } - data = json.dumps(data_json) - response = MockResponse(data, status_code=404) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.code == code - assert response_result.data.message == message - assert response_result.data.rootCause == rootCause - assert response_result.data.additionalinfo == additionalinfo - - def test_post_tags_created_now( - self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - created = 'now' - pet_name = 'Dog' - data_json = { - 'created': created, - 'name': pet_name, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters() - assert isinstance(body, BaseModel) - assert body.created == created - assert body.name == pet_name - - code = 400 - message = 'Bad request' - rootCause = 'Tag already exist' - additionalinfo = 'Tag Dog already exist' - data_json = { - 'code': 400, - 'message': 'Bad request', - 'rootCause': 'Tag already exist', - 'additionalinfo': 'Tag Dog already exist', - } - data = json.dumps(data_json) - response = MockResponse(data, status_code=404) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.code == code - assert response_result.data.message == message - assert response_result.data.rootCause == rootCause - assert response_result.data.additionalinfo == additionalinfo - - def test_post_tags_created_datetime( - self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - created = '2016-04-16T16:06:05Z' - pet_name = 'Dog' - data_json = { - 'created': created, - 'name': pet_name, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - body = validate_body(spec, request) - - assert parameters == RequestParameters() - assert isinstance(body, BaseModel) - assert body.created == datetime(2016, 4, 16, 16, 6, 5, tzinfo=UTC) - assert body.name == pet_name - - code = 400 - message = 'Bad request' - rootCause = 'Tag already exist' - additionalinfo = 'Tag Dog already exist' - response_data_json = { - 'code': code, - 'message': message, - 'rootCause': rootCause, - 'additionalinfo': additionalinfo, - } - response_data = json.dumps(response_data_json) - response = MockResponse(response_data, status_code=404) - - data = validate_data(spec, request, response) - - assert isinstance(data, BaseModel) - assert data.code == code - assert data.message == message - assert data.rootCause == rootCause - assert data.additionalinfo == additionalinfo - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.code == code - assert response_result.data.message == message - assert response_result.data.rootCause == rootCause - assert response_result.data.additionalinfo == additionalinfo - - def test_post_tags_created_invalid_type( - self, spec, response_validator): - host_url = 'http://petstore.swagger.io/v1' - path_pattern = '/v1/tags' - created = 'long time ago' - pet_name = 'Dog' - data_json = { - 'created': created, - 'name': pet_name, - } - data = json.dumps(data_json) - - request = MockRequest( - host_url, 'POST', '/tags', - path_pattern=path_pattern, data=data, - ) - - parameters = validate_parameters(spec, request) - with pytest.raises(InvalidSchemaValue): - validate_body(spec, request) - - assert parameters == RequestParameters() - - code = 400 - message = 'Bad request' - correlationId = UUID('a8098c1a-f86e-11da-bd1a-00112444be1e') - rootCause = 'Tag already exist' - additionalinfo = 'Tag Dog already exist' - data_json = { - 'code': code, - 'message': message, - 'correlationId': str(correlationId), - 'rootCause': rootCause, - 'additionalinfo': additionalinfo, - } - data = json.dumps(data_json) - response = MockResponse(data, status_code=404) - - response_result = response_validator.validate(request, response) - - assert response_result.errors == [] - assert isinstance(response_result.data, BaseModel) - assert response_result.data.code == code - assert response_result.data.message == message - assert response_result.data.correlationId == correlationId - assert response_result.data.rootCause == rootCause - assert response_result.data.additionalinfo == additionalinfo diff --git a/tests/integration/validation/test_read_only_write_only.py b/tests/integration/validation/test_read_only_write_only.py deleted file mode 100644 index 08cc6892..00000000 --- a/tests/integration/validation/test_read_only_write_only.py +++ /dev/null @@ -1,97 +0,0 @@ -import json - -import pytest - -from openapi_core.shortcuts import create_spec -from openapi_core.unmarshalling.schemas.exceptions import InvalidSchemaValue -from openapi_core.validation.response.validators import ResponseValidator -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.testing import MockRequest, MockResponse - - -@pytest.fixture -def response_validator(spec): - return ResponseValidator(spec) - - -@pytest.fixture -def request_validator(spec): - return RequestValidator(spec) - - -@pytest.fixture('class') -def spec(factory): - spec_dict = factory.spec_from_file("data/v3.0/read_only_write_only.yaml") - return create_spec(spec_dict) - - -class TestReadOnly(object): - - def test_write_a_read_only_property(self, request_validator): - data = json.dumps({ - 'id': 10, - 'name': "Pedro", - }) - - request = MockRequest(host_url='', method='POST', - path='/users', data=data) - - result = request_validator.validate(request) - - assert type(result.errors[0]) == InvalidSchemaValue - assert result.body is None - - def test_read_only_property_response(self, response_validator): - data = json.dumps({ - 'id': 10, - 'name': "Pedro", - }) - - request = MockRequest(host_url='', method='POST', - path='/users') - - response = MockResponse(data) - - result = response_validator.validate(request, response) - - assert not result.errors - assert result.data == { - 'id': 10, - 'name': "Pedro", - } - - -class TestWriteOnly(object): - - def test_write_only_property(self, request_validator): - data = json.dumps({ - 'name': "Pedro", - 'hidden': False, - }) - - request = MockRequest(host_url='', method='POST', - path='/users', data=data) - - result = request_validator.validate(request) - - assert not result.errors - assert result.body == { - 'name': "Pedro", - 'hidden': False, - } - - def test_read_a_write_only_property(self, response_validator): - data = json.dumps({ - 'id': 10, - 'name': "Pedro", - 'hidden': True, - }) - - request = MockRequest(host_url='', method='POST', - path='/users') - response = MockResponse(data) - - result = response_validator.validate(request, response) - - assert type(result.errors[0]) == InvalidSchemaValue - assert result.data is None diff --git a/tests/integration/validation/test_request_validators.py b/tests/integration/validation/test_request_validators.py new file mode 100644 index 00000000..eaac8dbf --- /dev/null +++ b/tests/integration/validation/test_request_validators.py @@ -0,0 +1,130 @@ +from base64 import b64encode + +import pytest + +from openapi_core import V30RequestValidator +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.security.exceptions import SecurityNotFound +from openapi_core.testing import MockRequest +from openapi_core.validation.request.exceptions import MissingRequiredParameter +from openapi_core.validation.request.exceptions import ( + RequestBodyValidationError, +) +from openapi_core.validation.request.exceptions import SecurityValidationError + + +class TestRequestValidator: + host_url = "http://petstore.swagger.io" + + api_key = "12345" + + @property + def api_key_encoded(self): + api_key_bytes = self.api_key.encode("utf8") + api_key_bytes_enc = b64encode(api_key_bytes) + return str(api_key_bytes_enc, "utf8") + + @pytest.fixture(scope="session") + def spec_dict(self, v30_petstore_content): + return v30_petstore_content + + @pytest.fixture(scope="session") + def spec(self, v30_petstore_spec): + return v30_petstore_spec + + @pytest.fixture(scope="session") + def request_validator(self, spec): + return V30RequestValidator(spec) + + def test_request_server_error(self, request_validator): + request = MockRequest("http://petstore.invalid.net/v1", "get", "/") + + with pytest.raises(PathNotFound): + request_validator.validate(request) + + def test_path_not_found(self, request_validator): + request = MockRequest(self.host_url, "get", "/v1") + + with pytest.raises(PathNotFound): + request_validator.validate(request) + + def test_operation_not_found(self, request_validator): + request = MockRequest(self.host_url, "patch", "/v1/pets") + + with pytest.raises(OperationNotFound): + request_validator.validate(request) + + def test_missing_parameter(self, request_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + + with pytest.raises(MissingRequiredParameter): + with pytest.warns(DeprecationWarning): + request_validator.validate(request) + + def test_security_not_found(self, request_validator): + request = MockRequest( + self.host_url, + "get", + "/v1/pets/1", + path_pattern="/v1/pets/{petId}", + view_args={"petId": "1"}, + ) + + with pytest.raises(SecurityValidationError) as exc_info: + request_validator.validate(request) + + assert exc_info.value.__cause__ == SecurityNotFound( + [["petstore_auth"]] + ) + + def test_media_type_not_found(self, request_validator): + data = b"csv,data" + headers = { + "api-key": self.api_key_encoded, + } + cookies = { + "user": "123", + } + request = MockRequest( + "https://development.gigantic-server.com", + "post", + "/v1/pets", + path_pattern="/v1/pets", + content_type="text/csv", + data=data, + headers=headers, + cookies=cookies, + ) + + with pytest.raises(RequestBodyValidationError) as exc_info: + request_validator.validate(request) + + assert exc_info.value.__cause__ == MediaTypeNotFound( + mimetype="text/csv", + availableMimetypes=[ + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + ], + ) + + def test_valid(self, request_validator): + authorization = "Basic " + self.api_key_encoded + headers = { + "Authorization": authorization, + } + request = MockRequest( + self.host_url, + "get", + "/v1/pets/1", + path_pattern="/v1/pets/{petId}", + view_args={"petId": "1"}, + headers=headers, + ) + + result = request_validator.validate(request) + + assert result is None diff --git a/tests/integration/validation/test_response_validators.py b/tests/integration/validation/test_response_validators.py new file mode 100644 index 00000000..dcc1c0a3 --- /dev/null +++ b/tests/integration/validation/test_response_validators.py @@ -0,0 +1,156 @@ +import json + +import pytest + +from openapi_core import V30ResponseValidator +from openapi_core.deserializing.media_types.exceptions import ( + MediaTypeDeserializeError, +) +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.responses.exceptions import ResponseNotFound +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.validation.response.exceptions import DataValidationError +from openapi_core.validation.response.exceptions import InvalidData +from openapi_core.validation.response.exceptions import InvalidHeader +from openapi_core.validation.response.exceptions import MissingData +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue + + +class TestResponseValidator: + host_url = "http://petstore.swagger.io" + + @pytest.fixture(scope="session") + def spec_dict(self, v30_petstore_content): + return v30_petstore_content + + @pytest.fixture(scope="session") + def spec(self, v30_petstore_spec): + return v30_petstore_spec + + @pytest.fixture(scope="session") + def response_validator(self, spec): + return V30ResponseValidator(spec) + + def test_invalid_server(self, response_validator): + request = MockRequest("http://petstore.invalid.net/v1", "get", "/") + response = MockResponse(b"Not Found", status_code=404) + + with pytest.raises(PathNotFound): + response_validator.validate(request, response) + + def test_invalid_operation(self, response_validator): + request = MockRequest(self.host_url, "patch", "/v1/pets") + response = MockResponse(b"Not Found", status_code=404) + + with pytest.raises(OperationNotFound): + response_validator.validate(request, response) + + def test_invalid_response(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"Not Found", status_code=409) + + with pytest.raises(ResponseNotFound): + response_validator.validate(request, response) + + def test_invalid_content_type(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"Not Found", content_type="text/csv") + + with pytest.raises(DataValidationError) as exc_info: + response_validator.validate(request, response) + + assert type(exc_info.value.__cause__) == MediaTypeNotFound + + def test_missing_body(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(None) + + with pytest.raises(MissingData): + response_validator.validate(request, response) + + def test_invalid_media_type(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"abcde") + + with pytest.raises(DataValidationError) as exc_info: + response_validator.validate(request, response) + + assert exc_info.value.__cause__ == MediaTypeDeserializeError( + mimetype="application/json", value=b"abcde" + ) + + def test_invalid_media_type_value(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response = MockResponse(b"{}") + + with pytest.raises(DataValidationError) as exc_info: + response_validator.validate(request, response) + + assert type(exc_info.value.__cause__) == InvalidSchemaValue + + def test_invalid_value(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/tags") + response_json = { + "data": [ + {"id": 1, "name": "Sparky"}, + ], + } + response_data = json.dumps(response_json).encode() + response = MockResponse(response_data) + + with pytest.raises(InvalidData) as exc_info: + response_validator.validate(request, response) + + assert type(exc_info.value.__cause__) == InvalidSchemaValue + + def test_invalid_header(self, response_validator): + request = MockRequest( + self.host_url, + "delete", + "/v1/tags", + path_pattern="/v1/tags", + ) + response_json = { + "data": [ + { + "id": 1, + "name": "Sparky", + "ears": { + "healthy": True, + }, + }, + ], + } + response_data = json.dumps(response_json).encode() + headers = { + "x-delete-confirm": "true", + "x-delete-date": "today", + } + response = MockResponse(response_data, headers=headers) + + with pytest.raises(InvalidHeader): + with pytest.warns(DeprecationWarning): + response_validator.validate(request, response) + + def test_valid(self, response_validator): + request = MockRequest(self.host_url, "get", "/v1/pets") + response_json = { + "data": [ + { + "id": 1, + "name": "Sparky", + "ears": { + "healthy": True, + }, + }, + ], + } + response_data = json.dumps(response_json).encode() + response = MockResponse(response_data) + + result = response_validator.validate(request, response) + + assert result is None diff --git a/tests/integration/validation/test_security_override.py b/tests/integration/validation/test_security_override.py deleted file mode 100644 index 370012c1..00000000 --- a/tests/integration/validation/test_security_override.py +++ /dev/null @@ -1,86 +0,0 @@ -from base64 import b64encode - -import pytest -from six import text_type - -from openapi_core.shortcuts import create_spec -from openapi_core.validation.exceptions import InvalidSecurity -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.testing import MockRequest - - -@pytest.fixture -def request_validator(spec): - return RequestValidator(spec) - - -@pytest.fixture('class') -def spec(factory): - spec_dict = factory.spec_from_file("data/v3.0/security_override.yaml") - return create_spec(spec_dict) - - -class TestSecurityOverride(object): - - host_url = 'http://petstore.swagger.io' - - api_key = '12345' - - @property - def api_key_encoded(self): - api_key_bytes = self.api_key.encode('utf8') - api_key_bytes_enc = b64encode(api_key_bytes) - return text_type(api_key_bytes_enc, 'utf8') - - def test_default(self, request_validator): - args = {'api_key': self.api_key} - request = MockRequest( - self.host_url, 'get', '/resource/one', args=args) - - result = request_validator.validate(request) - - assert not result.errors - assert result.security == { - 'api_key': self.api_key, - } - - def test_default_invalid(self, request_validator): - request = MockRequest(self.host_url, 'get', '/resource/one') - - result = request_validator.validate(request) - - assert type(result.errors[0]) == InvalidSecurity - assert result.security is None - - def test_override(self, request_validator): - authorization = 'Basic ' + self.api_key_encoded - headers = { - 'Authorization': authorization, - } - request = MockRequest( - self.host_url, 'post', '/resource/one', headers=headers) - - result = request_validator.validate(request) - - assert not result.errors - assert result.security == { - 'petstore_auth': self.api_key_encoded, - } - - def test_override_invalid(self, request_validator): - request = MockRequest( - self.host_url, 'post', '/resource/one') - - result = request_validator.validate(request) - - assert type(result.errors[0]) == InvalidSecurity - assert result.security is None - - def test_remove(self, request_validator): - request = MockRequest( - self.host_url, 'put', '/resource/one') - - result = request_validator.validate(request) - - assert not result.errors - assert result.security == {} diff --git a/tests/integration/validation/test_validators.py b/tests/integration/validation/test_validators.py deleted file mode 100644 index e244dfc4..00000000 --- a/tests/integration/validation/test_validators.py +++ /dev/null @@ -1,546 +0,0 @@ -from base64 import b64encode -import json -import pytest -from six import text_type - -from openapi_core.casting.schemas.exceptions import CastError -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.schema.media_types.exceptions import ( - InvalidContentType, -) -from openapi_core.extensions.models.models import BaseModel -from openapi_core.schema.parameters.exceptions import MissingRequiredParameter -from openapi_core.schema.request_bodies.exceptions import MissingRequestBody -from openapi_core.schema.responses.exceptions import ( - MissingResponseContent, InvalidResponse, -) -from openapi_core.shortcuts import create_spec -from openapi_core.templating.paths.exceptions import ( - PathNotFound, OperationNotFound, -) -from openapi_core.testing import MockRequest, MockResponse -from openapi_core.unmarshalling.schemas.exceptions import InvalidSchemaValue -from openapi_core.validation.exceptions import InvalidSecurity -from openapi_core.validation.request.datatypes import RequestParameters -from openapi_core.validation.request.validators import RequestValidator -from openapi_core.validation.response.validators import ResponseValidator - - -class TestRequestValidator(object): - - host_url = 'http://petstore.swagger.io' - - api_key = '12345' - - @property - def api_key_encoded(self): - api_key_bytes = self.api_key.encode('utf8') - api_key_bytes_enc = b64encode(api_key_bytes) - return text_type(api_key_bytes_enc, 'utf8') - - @pytest.fixture(scope='session') - def spec_dict(self, factory): - return factory.spec_from_file("data/v3.0/petstore.yaml") - - @pytest.fixture(scope='session') - def spec(self, spec_dict): - return create_spec(spec_dict) - - @pytest.fixture(scope='session') - def validator(self, spec): - return RequestValidator(spec, base_url=self.host_url) - - def test_request_server_error(self, validator): - request = MockRequest('http://petstore.invalid.net/v1', 'get', '/') - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == PathNotFound - assert result.body is None - assert result.parameters == RequestParameters() - - def test_invalid_path(self, validator): - request = MockRequest(self.host_url, 'get', '/v1') - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == PathNotFound - assert result.body is None - assert result.parameters == RequestParameters() - - def test_invalid_operation(self, validator): - request = MockRequest(self.host_url, 'patch', '/v1/pets') - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == OperationNotFound - assert result.body is None - assert result.parameters == RequestParameters() - - def test_missing_parameter(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - - result = validator.validate(request) - - assert type(result.errors[0]) == MissingRequiredParameter - assert result.body is None - assert result.parameters == RequestParameters( - query={ - 'page': 1, - 'search': '', - }, - ) - - def test_get_pets(self, validator): - args = {'limit': '10', 'ids': ['1', '2'], 'api_key': self.api_key} - request = MockRequest( - self.host_url, 'get', '/v1/pets', - path_pattern='/v1/pets', args=args, - ) - - result = validator.validate(request) - - assert result.errors == [] - assert result.body is None - assert result.parameters == RequestParameters( - query={ - 'limit': 10, - 'page': 1, - 'search': '', - 'ids': [1, 2], - }, - ) - assert result.security == { - 'api_key': self.api_key, - } - - def test_get_pets_webob(self, validator): - from webob.multidict import GetDict - request = MockRequest( - self.host_url, 'get', '/v1/pets', - path_pattern='/v1/pets', - ) - request.parameters.query = GetDict( - [('limit', '5'), ('ids', '1'), ('ids', '2')], - {} - ) - - result = validator.validate(request) - - assert result.errors == [] - assert result.body is None - assert result.parameters == RequestParameters( - query={ - 'limit': 5, - 'page': 1, - 'search': '', - 'ids': [1, 2], - }, - ) - - def test_missing_body(self, validator): - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - request = MockRequest( - 'https://development.gigantic-server.com', 'post', '/v1/pets', - path_pattern='/v1/pets', - headers=headers, cookies=cookies, - ) - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == MissingRequestBody - assert result.body is None - assert result.parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - def test_invalid_content_type(self, validator): - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - request = MockRequest( - 'https://development.gigantic-server.com', 'post', '/v1/pets', - path_pattern='/v1/pets', mimetype='text/csv', - headers=headers, cookies=cookies, - ) - - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == InvalidContentType - assert result.body is None - assert result.parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - - def test_post_pets(self, validator, spec_dict): - pet_name = 'Cat' - pet_tag = 'cats' - pet_street = 'Piekna' - pet_city = 'Warsaw' - data_json = { - 'name': pet_name, - 'tag': pet_tag, - 'position': 2, - 'address': { - 'street': pet_street, - 'city': pet_city, - }, - 'ears': { - 'healthy': True, - } - } - data = json.dumps(data_json) - headers = { - 'api_key': self.api_key_encoded, - } - cookies = { - 'user': '123', - } - request = MockRequest( - 'https://development.gigantic-server.com', 'post', '/v1/pets', - path_pattern='/v1/pets', data=data, - headers=headers, cookies=cookies, - ) - - result = validator.validate(request) - - assert result.errors == [] - assert result.parameters == RequestParameters( - header={ - 'api_key': self.api_key, - }, - cookie={ - 'user': 123, - }, - ) - assert result.security == {} - - schemas = spec_dict['components']['schemas'] - pet_model = schemas['PetCreate']['x-model'] - address_model = schemas['Address']['x-model'] - assert result.body.__class__.__name__ == pet_model - assert result.body.name == pet_name - assert result.body.tag == pet_tag - assert result.body.position == 2 - assert result.body.address.__class__.__name__ == address_model - assert result.body.address.street == pet_street - assert result.body.address.city == pet_city - - def test_get_pet_unauthorized(self, validator): - request = MockRequest( - self.host_url, 'get', '/v1/pets/1', - path_pattern='/v1/pets/{petId}', view_args={'petId': '1'}, - ) - - result = validator.validate(request) - - assert result.errors == [InvalidSecurity(), ] - assert result.body is None - assert result.parameters == RequestParameters() - assert result.security is None - - def test_get_pet(self, validator): - authorization = 'Basic ' + self.api_key_encoded - headers = { - 'Authorization': authorization, - } - request = MockRequest( - self.host_url, 'get', '/v1/pets/1', - path_pattern='/v1/pets/{petId}', view_args={'petId': '1'}, - headers=headers, - ) - - result = validator.validate(request) - - assert result.errors == [] - assert result.body is None - assert result.parameters == RequestParameters( - path={ - 'petId': 1, - }, - ) - assert result.security == { - 'petstore_auth': self.api_key_encoded, - } - - -class TestPathItemParamsValidator(object): - - @pytest.fixture(scope='session') - def spec_dict(self): - return { - "openapi": "3.0.0", - "info": { - "title": "Test path item parameter validation", - "version": "0.1", - }, - "paths": { - "/resource": { - "parameters": [ - { - "name": "resId", - "in": "query", - "required": True, - "schema": { - "type": "integer", - }, - }, - ], - "get": { - "responses": { - "default": { - "description": "Return the resource." - } - } - } - } - } - } - - @pytest.fixture(scope='session') - def spec(self, spec_dict): - return create_spec(spec_dict) - - @pytest.fixture(scope='session') - def validator(self, spec): - return RequestValidator(spec, base_url='http://example.com') - - def test_request_missing_param(self, validator): - request = MockRequest('http://example.com', 'get', '/resource') - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == MissingRequiredParameter - assert result.body is None - assert result.parameters == RequestParameters() - - def test_request_invalid_param(self, validator): - request = MockRequest( - 'http://example.com', 'get', '/resource', - args={'resId': 'invalid'}, - ) - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == CastError - assert result.body is None - assert result.parameters == RequestParameters() - - def test_request_valid_param(self, validator): - request = MockRequest( - 'http://example.com', 'get', '/resource', - args={'resId': '10'}, - ) - result = validator.validate(request) - - assert len(result.errors) == 0 - assert result.body is None - assert result.parameters == RequestParameters(query={'resId': 10}) - - def test_request_override_param(self, spec_dict): - # override path parameter on operation - spec_dict["paths"]["/resource"]["get"]["parameters"] = [ - { - # full valid parameter object required - "name": "resId", - "in": "query", - "required": False, - "schema": { - "type": "integer", - }, - } - ] - validator = RequestValidator( - create_spec(spec_dict), base_url='http://example.com') - request = MockRequest('http://example.com', 'get', '/resource') - result = validator.validate(request) - - assert len(result.errors) == 0 - assert result.body is None - assert result.parameters == RequestParameters() - - def test_request_override_param_uniqueness(self, spec_dict): - # add parameter on operation with same name as on path but - # different location - spec_dict["paths"]["/resource"]["get"]["parameters"] = [ - { - # full valid parameter object required - "name": "resId", - "in": "header", - "required": False, - "schema": { - "type": "integer", - }, - } - ] - validator = RequestValidator( - create_spec(spec_dict), base_url='http://example.com') - request = MockRequest('http://example.com', 'get', '/resource') - result = validator.validate(request) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == MissingRequiredParameter - assert result.body is None - assert result.parameters == RequestParameters() - - -class TestResponseValidator(object): - - host_url = 'http://petstore.swagger.io' - - @pytest.fixture - def spec_dict(self, factory): - return factory.spec_from_file("data/v3.0/petstore.yaml") - - @pytest.fixture - def spec(self, spec_dict): - return create_spec(spec_dict) - - @pytest.fixture - def validator(self, spec): - return ResponseValidator(spec, base_url=self.host_url) - - def test_invalid_server(self, validator): - request = MockRequest('http://petstore.invalid.net/v1', 'get', '/') - response = MockResponse('Not Found', status_code=404) - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == PathNotFound - assert result.data is None - assert result.headers == {} - - def test_invalid_operation(self, validator): - request = MockRequest(self.host_url, 'patch', '/v1/pets') - response = MockResponse('Not Found', status_code=404) - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == OperationNotFound - assert result.data is None - assert result.headers == {} - - def test_invalid_response(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response = MockResponse('Not Found', status_code=409) - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == InvalidResponse - assert result.data is None - assert result.headers == {} - - def test_invalid_content_type(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response = MockResponse('Not Found', mimetype='text/csv') - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == InvalidContentType - assert result.data is None - assert result.headers == {} - - def test_missing_body(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response = MockResponse(None) - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == MissingResponseContent - assert result.data is None - assert result.headers == {} - - def test_invalid_media_type(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response = MockResponse("abcde") - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == DeserializeError - assert result.data is None - assert result.headers == {} - - def test_invalid_media_type_value(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response = MockResponse("{}") - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == InvalidSchemaValue - assert result.data is None - assert result.headers == {} - - def test_invalid_value(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/tags') - response_json = { - 'data': [ - { - 'id': 1, - 'name': 'Sparky' - }, - ], - } - response_data = json.dumps(response_json) - response = MockResponse(response_data) - - result = validator.validate(request, response) - - assert len(result.errors) == 1 - assert type(result.errors[0]) == InvalidSchemaValue - assert result.data is None - assert result.headers == {} - - def test_get_pets(self, validator): - request = MockRequest(self.host_url, 'get', '/v1/pets') - response_json = { - 'data': [ - { - 'id': 1, - 'name': 'Sparky', - 'ears': { - 'healthy': True, - }, - }, - ], - } - response_data = json.dumps(response_json) - response = MockResponse(response_data) - - result = validator.validate(request, response) - - assert result.errors == [] - assert isinstance(result.data, BaseModel) - assert len(result.data.data) == 1 - assert result.data.data[0].id == 1 - assert result.data.data[0].name == 'Sparky' - assert result.headers == {} diff --git a/tests/unit/casting/test_schema_casters.py b/tests/unit/casting/test_schema_casters.py new file mode 100644 index 00000000..39c0235c --- /dev/null +++ b/tests/unit/casting/test_schema_casters.py @@ -0,0 +1,63 @@ +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.casting.schemas import oas31_schema_casters_factory +from openapi_core.casting.schemas.exceptions import CastError + + +class TestSchemaCaster: + @pytest.fixture + def caster_factory(self): + def create_caster(schema): + return oas31_schema_casters_factory.create(schema) + + return create_caster + + @pytest.mark.parametrize( + "schema_type,value,expected", + [ + ("integer", "2", 2), + ("number", "3.14", 3.14), + ("boolean", "false", False), + ("boolean", "true", True), + ], + ) + def test_primitive_flat( + self, caster_factory, schema_type, value, expected + ): + spec = { + "type": schema_type, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(value) + + assert result == expected + + def test_array_invalid_type(self, caster_factory): + spec = { + "type": "array", + "items": { + "type": "number", + }, + } + schema = SchemaPath.from_dict(spec) + value = ["test", "test2"] + + with pytest.raises(CastError): + caster_factory(schema).cast(value) + + @pytest.mark.parametrize("value", [3.14, "foo", b"foo"]) + def test_array_invalid_value(self, value, caster_factory): + spec = { + "type": "array", + "items": { + "oneOf": [{"type": "number"}, {"type": "string"}], + }, + } + schema = SchemaPath.from_dict(spec) + + with pytest.raises( + CastError, match=f"Failed to cast value to array type: {value}" + ): + caster_factory(schema).cast(value) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..cb19dafb --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,69 @@ +from json import dumps +from os import unlink +from tempfile import NamedTemporaryFile + +import pytest +from jsonschema_path import SchemaPath + + +@pytest.fixture +def spec_v20(): + return SchemaPath.from_dict( + { + "swagger": "2.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + ) + + +@pytest.fixture +def spec_v30(): + return SchemaPath.from_dict( + { + "openapi": "3.0.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + ) + + +@pytest.fixture +def spec_v31(): + return SchemaPath.from_dict( + { + "openapi": "3.1.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + ) + + +@pytest.fixture +def spec_invalid(): + return SchemaPath.from_dict({}) + + +@pytest.fixture +def create_file(): + files = [] + + def create(schema): + contents = dumps(schema).encode("utf-8") + with NamedTemporaryFile(delete=False) as tf: + files.append(tf) + tf.write(contents) + return tf.name + + yield create + for tf in files: + unlink(tf.name) diff --git a/tests/unit/contrib/aiohttp/test_aiohttp_requests.py b/tests/unit/contrib/aiohttp/test_aiohttp_requests.py new file mode 100644 index 00000000..20c8afc5 --- /dev/null +++ b/tests/unit/contrib/aiohttp/test_aiohttp_requests.py @@ -0,0 +1,9 @@ +import pytest + +from openapi_core.contrib.aiohttp.requests import AIOHTTPOpenAPIWebRequest + + +class TestAIOHTTPOpenAPIWebRequest: + def test_type_invalid(self): + with pytest.raises(TypeError): + AIOHTTPOpenAPIWebRequest(None) diff --git a/tests/unit/contrib/aiohttp/test_aiohttp_responses.py b/tests/unit/contrib/aiohttp/test_aiohttp_responses.py new file mode 100644 index 00000000..3ef1580a --- /dev/null +++ b/tests/unit/contrib/aiohttp/test_aiohttp_responses.py @@ -0,0 +1,9 @@ +import pytest + +from openapi_core.contrib.aiohttp.responses import AIOHTTPOpenAPIWebResponse + + +class TestAIOHTTPOpenAPIWebResponse: + def test_type_invalid(self): + with pytest.raises(TypeError): + AIOHTTPOpenAPIWebResponse(None) diff --git a/tests/unit/contrib/django/test_django.py b/tests/unit/contrib/django/test_django.py new file mode 100644 index 00000000..49621937 --- /dev/null +++ b/tests/unit/contrib/django/test_django.py @@ -0,0 +1,197 @@ +import pytest +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.contrib.django import DjangoOpenAPIRequest +from openapi_core.contrib.django import DjangoOpenAPIResponse +from openapi_core.datatypes import RequestParameters + + +class BaseTestDjango: + @pytest.fixture(autouse=True, scope="module") + def django_settings(self): + import django + from django.conf import settings + from django.contrib import admin + from django.urls import path + from django.urls import re_path + + if settings.configured: + from django.utils.functional import empty + + settings._wrapped = empty + + settings.configure( + SECRET_KEY="secretkey", + ALLOWED_HOSTS=[ + "testserver", + ], + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.messages", + "django.contrib.sessions", + ], + MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + ], + ) + django.setup() + settings.ROOT_URLCONF = ( + path("admin/", admin.site.urls), + re_path("^test/test-regexp/$", lambda d: None), + re_path("^object/(?P[^/.]+)/action/$", lambda d: None), + ) + + @pytest.fixture + def request_factory(self): + from django.test.client import RequestFactory + + return RequestFactory() + + @pytest.fixture + def response_factory(self): + from django.http import HttpResponse + + def create(content=b"", status_code=None): + return HttpResponse(content, status=status_code) + + return create + + +class TestDjangoOpenAPIRequest(BaseTestDjango): + def test_type_invalid(self): + with pytest.raises(TypeError): + DjangoOpenAPIRequest(None) + + def test_no_resolver(self, request_factory): + data = {"test1": "test2"} + request = request_factory.get("/admin/", data) + + openapi_request = DjangoOpenAPIRequest(request) + + assert openapi_request.parameters == RequestParameters( + path={}, + query=ImmutableMultiDict([("test1", "test2")]), + header=Headers({"Cookie": ""}), + cookie={}, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.path_pattern is None + assert openapi_request.body == b"" + assert openapi_request.content_type == request.content_type + + def test_simple(self, request_factory): + from django.urls import resolve + + request = request_factory.get("/admin/") + request.resolver_match = resolve(request.path) + + openapi_request = DjangoOpenAPIRequest(request) + + assert openapi_request.parameters == RequestParameters( + path={}, + query={}, + header=Headers({"Cookie": ""}), + cookie={}, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.path_pattern == request.path + assert openapi_request.body == b"" + assert openapi_request.content_type == request.content_type + + def test_url_rule(self, request_factory): + from django.urls import resolve + + request = request_factory.get("/admin/auth/group/1/") + request.resolver_match = resolve(request.path) + + openapi_request = DjangoOpenAPIRequest(request) + + assert openapi_request.parameters == RequestParameters( + path={"object_id": "1"}, + query={}, + header=Headers({"Cookie": ""}), + cookie={}, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.path_pattern == "/admin/auth/group/{object_id}/" + assert openapi_request.body == b"" + assert openapi_request.content_type == request.content_type + + def test_url_regexp_pattern(self, request_factory): + from django.urls import resolve + + request = request_factory.get("/test/test-regexp/") + request.resolver_match = resolve(request.path) + + openapi_request = DjangoOpenAPIRequest(request) + + assert openapi_request.parameters == RequestParameters( + path={}, + query={}, + header=Headers({"Cookie": ""}), + cookie={}, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.path_pattern == request.path + assert openapi_request.body == b"" + assert openapi_request.content_type == request.content_type + + def test_drf_default_value_pattern(self, request_factory): + from django.urls import resolve + + request = request_factory.get("/object/123/action/") + request.resolver_match = resolve(request.path) + + openapi_request = DjangoOpenAPIRequest(request) + + assert openapi_request.parameters == RequestParameters( + path={"pk": "123"}, + query={}, + header=Headers({"Cookie": ""}), + cookie={}, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == request._current_scheme_host + assert openapi_request.path == request.path + assert openapi_request.path_pattern == "/object/{pk}/action/" + assert openapi_request.body == b"" + assert openapi_request.content_type == request.content_type + + +class TestDjangoOpenAPIResponse(BaseTestDjango): + def test_type_invalid(self): + with pytest.raises(TypeError): + DjangoOpenAPIResponse(None) + + def test_stream_response(self, response_factory): + response = response_factory() + response.writelines(["foo\n", "bar\n", "baz\n"]) + + openapi_response = DjangoOpenAPIResponse(response) + + assert openapi_response.data == b"foo\nbar\nbaz\n" + assert openapi_response.status_code == response.status_code + assert openapi_response.content_type == response["Content-Type"] + + def test_redirect_response(self, response_factory): + data = b"/redirected/" + response = response_factory(data, status_code=302) + + openapi_response = DjangoOpenAPIResponse(response) + + assert openapi_response.data == data + assert openapi_response.status_code == response.status_code + assert openapi_response.content_type == response["Content-Type"] diff --git a/tests/unit/contrib/flask/conftest.py b/tests/unit/contrib/flask/conftest.py new file mode 100644 index 00000000..a59e1d52 --- /dev/null +++ b/tests/unit/contrib/flask/conftest.py @@ -0,0 +1,68 @@ +import pytest +from flask.wrappers import Request +from flask.wrappers import Response +from werkzeug.routing import Map +from werkzeug.routing import Rule +from werkzeug.routing import Subdomain +from werkzeug.test import create_environ + + +@pytest.fixture +def environ_factory(): + return create_environ + + +@pytest.fixture +def map(): + return Map( + [ + # Static URLs + Rule("/", endpoint="static/index"), + Rule("/about", endpoint="static/about"), + Rule("/help", endpoint="static/help"), + # Knowledge Base + Subdomain( + "kb", + [ + Rule("/", endpoint="kb/index"), + Rule("/browse/", endpoint="kb/browse"), + Rule("/browse//", endpoint="kb/browse"), + Rule("/browse//", endpoint="kb/browse"), + ], + ), + ], + default_subdomain="www", + ) + + +@pytest.fixture +def request_factory(map, environ_factory): + server_name = "localhost" + + def create_request(method, path, subdomain=None, query_string=None): + environ = environ_factory(query_string=query_string) + req = Request(environ) + urls = map.bind_to_environ( + environ, server_name=server_name, subdomain=subdomain + ) + req.url_rule, req.view_args = urls.match( + path, method, return_rule=True + ) + return req + + return create_request + + +@pytest.fixture +def response_factory(): + def create_response( + data, status_code=200, headers=None, content_type="application/json" + ): + return Response( + data, + status=status_code, + headers=headers, + content_type=content_type, + ) + + return create_response diff --git a/tests/unit/contrib/flask/test_flask_requests.py b/tests/unit/contrib/flask/test_flask_requests.py new file mode 100644 index 00000000..48209cc6 --- /dev/null +++ b/tests/unit/contrib/flask/test_flask_requests.py @@ -0,0 +1,83 @@ +import pytest +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.contrib.flask import FlaskOpenAPIRequest +from openapi_core.datatypes import RequestParameters + + +class TestFlaskOpenAPIRequest: + def test_type_invalid(self): + with pytest.raises(TypeError): + FlaskOpenAPIRequest(None) + + def test_simple(self, request_factory, request): + request = request_factory("GET", "/", subdomain="www") + + openapi_request = FlaskOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict([]) + headers = Headers(request.headers) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == "get" + assert openapi_request.host_url == request.host_url + assert openapi_request.path == request.path + assert openapi_request.body == b"" + assert openapi_request.content_type == "application/octet-stream" + + def test_multiple_values(self, request_factory, request): + request = request_factory( + "GET", "/", subdomain="www", query_string="a=b&a=c" + ) + + openapi_request = FlaskOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict( + [ + ("a", "b"), + ("a", "c"), + ] + ) + headers = Headers(request.headers) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == "get" + assert openapi_request.host_url == request.host_url + assert openapi_request.path == request.path + assert openapi_request.body == b"" + assert openapi_request.content_type == "application/octet-stream" + + def test_url_rule(self, request_factory, request): + request = request_factory("GET", "/browse/12/", subdomain="kb") + + openapi_request = FlaskOpenAPIRequest(request) + + path = {"id": 12} + query = ImmutableMultiDict([]) + headers = Headers(request.headers) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == "get" + assert openapi_request.host_url == request.host_url + assert openapi_request.path == request.path + assert openapi_request.path_pattern == "/browse/{id}/" + assert openapi_request.body == b"" + assert openapi_request.content_type == "application/octet-stream" diff --git a/tests/unit/contrib/flask/test_flask_responses.py b/tests/unit/contrib/flask/test_flask_responses.py new file mode 100644 index 00000000..c2b893ac --- /dev/null +++ b/tests/unit/contrib/flask/test_flask_responses.py @@ -0,0 +1,20 @@ +import pytest + +from openapi_core.contrib.flask import FlaskOpenAPIResponse + + +class TestFlaskOpenAPIResponse: + def test_type_invalid(self): + with pytest.raises(TypeError): + FlaskOpenAPIResponse(None) + + def test_invalid_server(self, response_factory): + data = b"Not Found" + status_code = 404 + response = response_factory(data, status_code=status_code) + + openapi_response = FlaskOpenAPIResponse(response) + + assert openapi_response.data == data + assert openapi_response.status_code == status_code + assert openapi_response.content_type == response.mimetype diff --git a/tests/unit/contrib/requests/conftest.py b/tests/unit/contrib/requests/conftest.py new file mode 100644 index 00000000..121b5149 --- /dev/null +++ b/tests/unit/contrib/requests/conftest.py @@ -0,0 +1,52 @@ +from io import BytesIO +from urllib.parse import parse_qs +from urllib.parse import urljoin + +import pytest +from requests.models import Request +from requests.models import Response +from requests.structures import CaseInsensitiveDict +from urllib3.response import HTTPResponse + + +@pytest.fixture +def request_factory(): + schema = "http" + server_name = "localhost" + + def create_request( + method, + path, + subdomain=None, + query_string="", + content_type="application/json", + ): + base_url = "://".join([schema, server_name]) + url = urljoin(base_url, path) + params = parse_qs(query_string) + headers = { + "Content-Type": content_type, + } + return Request(method, url, params=params, headers=headers) + + return create_request + + +@pytest.fixture +def response_factory(): + def create_response( + data, status_code=200, content_type="application/json" + ): + fp = BytesIO(data) + raw = HTTPResponse(fp, preload_content=False) + resp = Response() + resp.headers = CaseInsensitiveDict( + { + "Content-Type": content_type, + } + ) + resp.status_code = status_code + resp.raw = raw + return resp + + return create_response diff --git a/tests/unit/contrib/requests/test_requests_requests.py b/tests/unit/contrib/requests/test_requests_requests.py new file mode 100644 index 00000000..415ad744 --- /dev/null +++ b/tests/unit/contrib/requests/test_requests_requests.py @@ -0,0 +1,146 @@ +import pytest +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.contrib.requests import RequestsOpenAPIRequest +from openapi_core.datatypes import RequestParameters + + +class TestRequestsOpenAPIRequest: + def test_type_invalid(self): + with pytest.raises(TypeError): + RequestsOpenAPIRequest(None) + + def test_simple(self, request_factory, request): + request = request_factory("GET", "/", subdomain="www") + + openapi_request = RequestsOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict([]) + headers = Headers(dict(request.headers)) + cookies = {} + prepared = request.prepare() + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == "http://localhost" + assert openapi_request.path == "/" + assert openapi_request.body == prepared.body + assert openapi_request.content_type == "application/json" + + def test_multiple_values(self, request_factory, request): + request = request_factory( + "GET", "/", subdomain="www", query_string="a=b&a=c" + ) + + openapi_request = RequestsOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict( + [ + ("a", "b"), + ("a", "c"), + ] + ) + headers = Headers(dict(request.headers)) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + prepared = request.prepare() + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == "http://localhost" + assert openapi_request.path == "/" + assert openapi_request.body == prepared.body + assert openapi_request.content_type == "application/json" + + def test_url_rule(self, request_factory, request): + request = request_factory("GET", "/browse/12/", subdomain="kb") + + openapi_request = RequestsOpenAPIRequest(request) + + # empty when not bound to spec + path = {} + query = ImmutableMultiDict([]) + headers = Headers( + { + "Content-Type": "application/json", + } + ) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + prepared = request.prepare() + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == "http://localhost" + assert openapi_request.path == "/browse/12/" + assert openapi_request.body == prepared.body + assert openapi_request.content_type == "application/json" + + def test_hash_param(self, request_factory, request): + request = request_factory("GET", "/browse/#12", subdomain="kb") + + openapi_request = RequestsOpenAPIRequest(request) + + # empty when not bound to spec + path = {} + query = ImmutableMultiDict([]) + headers = Headers( + { + "Content-Type": "application/json", + } + ) + cookies = {} + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + prepared = request.prepare() + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == "http://localhost" + assert openapi_request.path == "/browse/#12" + assert openapi_request.body == prepared.body + assert openapi_request.content_type == "application/json" + + def test_content_type_with_charset(self, request_factory, request): + request = request_factory( + "GET", + "/", + subdomain="www", + content_type="application/json; charset=utf-8", + ) + + openapi_request = RequestsOpenAPIRequest(request) + + path = {} + query = ImmutableMultiDict([]) + headers = Headers(dict(request.headers)) + cookies = {} + prepared = request.prepare() + assert openapi_request.parameters == RequestParameters( + path=path, + query=query, + header=headers, + cookie=cookies, + ) + assert openapi_request.method == request.method.lower() + assert openapi_request.host_url == "http://localhost" + assert openapi_request.path == "/" + assert openapi_request.body == prepared.body + assert ( + openapi_request.content_type == "application/json; charset=utf-8" + ) diff --git a/tests/unit/contrib/requests/test_requests_responses.py b/tests/unit/contrib/requests/test_requests_responses.py new file mode 100644 index 00000000..f032e658 --- /dev/null +++ b/tests/unit/contrib/requests/test_requests_responses.py @@ -0,0 +1,21 @@ +import pytest + +from openapi_core.contrib.requests import RequestsOpenAPIResponse + + +class TestRequestsOpenAPIResponse: + def test_type_invalid(self): + with pytest.raises(TypeError): + RequestsOpenAPIResponse(None) + + def test_invalid_server(self, response_factory): + data = b"Not Found" + status_code = 404 + response = response_factory(data, status_code=status_code) + + openapi_response = RequestsOpenAPIResponse(response) + + assert openapi_response.data == data + assert openapi_response.status_code == status_code + mimetype = response.headers.get("Content-Type") + assert openapi_response.content_type == mimetype diff --git a/tests/unit/deserializing/test_deserialize.py b/tests/unit/deserializing/test_deserialize.py deleted file mode 100644 index f5b29215..00000000 --- a/tests/unit/deserializing/test_deserialize.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -from openapi_core.deserializing.exceptions import DeserializeError -from openapi_core.deserializing.media_types.factories import ( - MediaTypeDeserializersFactory, -) -from openapi_core.deserializing.parameters.factories import ( - ParameterDeserializersFactory, -) -from openapi_core.deserializing.parameters.exceptions import ( - EmptyParameterValue, -) -from openapi_core.schema.media_types.models import MediaType -from openapi_core.schema.parameters.models import Parameter - - -class TestParameterDeserialise(object): - - @pytest.fixture - def deserializer_factory(self): - def create_deserializer(param): - return ParameterDeserializersFactory().create(param) - return create_deserializer - - def test_deprecated(self, deserializer_factory): - param = Parameter('param', 'query', deprecated=True) - value = 'test' - - with pytest.warns(DeprecationWarning): - result = deserializer_factory(param)(value) - - assert result == value - - def test_query_empty(self, deserializer_factory): - param = Parameter('param', 'query') - value = '' - - with pytest.raises(EmptyParameterValue): - deserializer_factory(param)(value) - - def test_query_valid(self, deserializer_factory): - param = Parameter('param', 'query') - value = 'test' - - result = deserializer_factory(param)(value) - - assert result == value - - -class TestMediaTypeDeserialise(object): - - @pytest.fixture - def deserializer_factory(self): - def create_deserializer(media_type, custom_deserializers=None): - return MediaTypeDeserializersFactory( - custom_deserializers=custom_deserializers).create(media_type) - return create_deserializer - - def test_empty(self, deserializer_factory): - media_type = MediaType('application/json') - value = '' - - with pytest.raises(DeserializeError): - deserializer_factory(media_type)(value) - - def test_no_schema_deserialised(self, deserializer_factory): - media_type = MediaType('application/json') - value = "{}" - - result = deserializer_factory(media_type)(value) - - assert result == {} - - def test_no_schema_custom_deserialiser(self, deserializer_factory): - custom_mimetype = 'application/custom' - media_type = MediaType(custom_mimetype) - value = "{}" - - def custom_deserializer(value): - return 'custom' - custom_deserializers = { - custom_mimetype: custom_deserializer, - } - - result = deserializer_factory( - media_type, custom_deserializers=custom_deserializers)(value) - - assert result == 'custom' diff --git a/tests/unit/deserializing/test_media_types_deserializers.py b/tests/unit/deserializing/test_media_types_deserializers.py new file mode 100644 index 00000000..5b8104a2 --- /dev/null +++ b/tests/unit/deserializing/test_media_types_deserializers.py @@ -0,0 +1,440 @@ +from xml.etree.ElementTree import Element + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.deserializing.exceptions import DeserializeError +from openapi_core.deserializing.media_types import media_type_deserializers +from openapi_core.deserializing.media_types.factories import ( + MediaTypeDeserializersFactory, +) +from openapi_core.deserializing.styles import style_deserializers_factory + + +class TestMediaTypeDeserializer: + @pytest.fixture + def deserializer_factory(self): + def create_deserializer( + mimetype, + schema=None, + encoding=None, + parameters=None, + media_type_deserializers=media_type_deserializers, + extra_media_type_deserializers=None, + ): + return MediaTypeDeserializersFactory( + style_deserializers_factory, + media_type_deserializers, + ).create( + mimetype, + schema=schema, + parameters=parameters, + encoding=encoding, + extra_media_type_deserializers=extra_media_type_deserializers, + ) + + return create_deserializer + + @pytest.mark.parametrize( + "mimetype,parameters,value,expected", + [ + ( + "text/plain", + {"charset": "iso-8859-2"}, + b"\xb1\xb6\xbc\xe6", + "ąśźć", + ), + ( + "text/plain", + {"charset": "utf-8"}, + b"\xc4\x85\xc5\x9b\xc5\xba\xc4\x87", + "ąśźć", + ), + ("text/plain", {}, b"\xc4\x85\xc5\x9b\xc5\xba\xc4\x87", "ąśźć"), + ("text/plain", {}, "somestr", "somestr"), + ("text/html", {}, "somestr", "somestr"), + ], + ) + def test_plain_valid( + self, deserializer_factory, mimetype, parameters, value, expected + ): + deserializer = deserializer_factory(mimetype, parameters=parameters) + + result = deserializer.deserialize(value) + + assert result == expected + + @pytest.mark.parametrize( + "mimetype", + [ + "application/json", + "application/vnd.api+json", + ], + ) + def test_json_valid(self, deserializer_factory, mimetype): + parameters = {"charset": "utf-8"} + deserializer = deserializer_factory(mimetype, parameters=parameters) + value = b'{"test": "test"}' + + result = deserializer.deserialize(value) + + assert type(result) is dict + assert result == {"test": "test"} + + @pytest.mark.parametrize( + "mimetype", + [ + "application/json", + "application/vnd.api+json", + ], + ) + def test_json_empty(self, deserializer_factory, mimetype): + deserializer = deserializer_factory(mimetype) + value = b"" + + with pytest.raises(DeserializeError): + deserializer.deserialize(value) + + @pytest.mark.parametrize( + "mimetype", + [ + "application/json", + "application/vnd.api+json", + ], + ) + def test_json_empty_object(self, deserializer_factory, mimetype): + deserializer = deserializer_factory(mimetype) + value = b"{}" + + result = deserializer.deserialize(value) + + assert result == {} + + @pytest.mark.parametrize( + "mimetype", + [ + "application/xml", + "application/xhtml+xml", + ], + ) + def test_xml_empty(self, deserializer_factory, mimetype): + deserializer = deserializer_factory(mimetype) + value = b"" + + with pytest.raises(DeserializeError): + deserializer.deserialize(value) + + @pytest.mark.parametrize( + "mimetype", + [ + "application/xml", + "application/xhtml+xml", + ], + ) + def test_xml_default_charset_valid(self, deserializer_factory, mimetype): + deserializer = deserializer_factory(mimetype) + value = b"text" + + result = deserializer.deserialize(value) + + assert type(result) is Element + + @pytest.mark.parametrize( + "mimetype", + [ + "application/xml", + "application/xhtml+xml", + ], + ) + def test_xml_valid(self, deserializer_factory, mimetype): + parameters = {"charset": "utf-8"} + deserializer = deserializer_factory(mimetype, parameters=parameters) + value = b"text" + + result = deserializer.deserialize(value) + + assert type(result) is Element + + def test_octet_stream_empty(self, deserializer_factory): + mimetype = "application/octet-stream" + deserializer = deserializer_factory(mimetype) + value = b"" + + result = deserializer.deserialize(value) + + assert result == b"" + + @pytest.mark.parametrize( + "mimetype", + [ + "image/gif", + "image/png", + ], + ) + def test_octet_stream_implicit(self, deserializer_factory, mimetype): + deserializer = deserializer_factory(mimetype) + value = b"" + + result = deserializer.deserialize(value) + + assert result == value + + def test_octet_stream_simple(self, deserializer_factory): + mimetype = "application/octet-stream" + schema_dict = {} + schema = SchemaPath.from_dict(schema_dict) + deserializer = deserializer_factory(mimetype, schema=schema) + value = b"test" + + result = deserializer.deserialize(value) + + assert result == b"test" + + def test_urlencoded_form_empty(self, deserializer_factory): + mimetype = "application/x-www-form-urlencoded" + schema_dict = {} + schema = SchemaPath.from_dict(schema_dict) + deserializer = deserializer_factory(mimetype, schema=schema) + value = b"" + + result = deserializer.deserialize(value) + + assert result == {} + + def test_urlencoded_form_simple(self, deserializer_factory): + mimetype = "application/x-www-form-urlencoded" + schema_dict = { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + encoding_dict = { + "name": { + "style": "form", + }, + } + encoding = SchemaPath.from_dict(encoding_dict) + deserializer = deserializer_factory( + mimetype, schema=schema, encoding=encoding + ) + value = b"name=foo+bar" + + result = deserializer.deserialize(value) + + assert result == { + "name": "foo bar", + } + + def test_urlencoded_complex(self, deserializer_factory): + mimetype = "application/x-www-form-urlencoded" + schema_dict = { + "type": "object", + "properties": { + "prop": { + "type": "array", + "items": { + "type": "integer", + }, + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + deserializer = deserializer_factory(mimetype, schema=schema) + value = b"prop=a&prop=b&prop=c" + + result = deserializer.deserialize(value) + + assert result == { + "prop": ["a", "b", "c"], + } + + def test_urlencoded_content_type(self, deserializer_factory): + mimetype = "application/x-www-form-urlencoded" + schema_dict = { + "type": "object", + "properties": { + "prop": { + "type": "array", + "items": { + "type": "integer", + }, + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + encoding_dict = { + "prop": { + "contentType": "application/json", + }, + } + encoding = SchemaPath.from_dict(encoding_dict) + deserializer = deserializer_factory( + mimetype, schema=schema, encoding=encoding + ) + value = b'prop=["a","b","c"]' + + result = deserializer.deserialize(value) + + assert result == { + "prop": ["a", "b", "c"], + } + + def test_urlencoded_deepobject(self, deserializer_factory): + mimetype = "application/x-www-form-urlencoded" + schema_dict = { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "R": { + "type": "integer", + }, + "G": { + "type": "integer", + }, + "B": { + "type": "integer", + }, + }, + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + encoding_dict = { + "color": { + "style": "deepObject", + "explode": True, + }, + } + encoding = SchemaPath.from_dict(encoding_dict) + deserializer = deserializer_factory( + mimetype, schema=schema, encoding=encoding + ) + value = b"color[R]=100&color[G]=200&color[B]=150" + + result = deserializer.deserialize(value) + + assert result == { + "color": { + "R": "100", + "G": "200", + "B": "150", + }, + } + + def test_multipart_form_empty(self, deserializer_factory): + mimetype = "multipart/form-data" + schema_dict = {} + schema = SchemaPath.from_dict(schema_dict) + deserializer = deserializer_factory(mimetype, schema=schema) + value = b"" + + result = deserializer.deserialize(value) + + assert result == {} + + def test_multipart_form_simple(self, deserializer_factory): + mimetype = "multipart/form-data" + schema_dict = { + "type": "object", + "properties": { + "param1": { + "type": "string", + "format": "binary", + }, + "param2": { + "type": "string", + "format": "binary", + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + encoding_dict = { + "param1": { + "contentType": "application/octet-stream", + }, + } + encoding = SchemaPath.from_dict(encoding_dict) + parameters = { + "boundary": "===============2872712225071193122==", + } + deserializer = deserializer_factory( + mimetype, schema=schema, parameters=parameters, encoding=encoding + ) + value = ( + b"--===============2872712225071193122==\n" + b"Content-Type: text/plain\nMIME-Version: 1.0\n" + b'Content-Disposition: form-data; name="param1"\n\ntest\n' + b"--===============2872712225071193122==\n" + b"Content-Type: text/plain\nMIME-Version: 1.0\n" + b'Content-Disposition: form-data; name="param2"\n\ntest2\n' + b"--===============2872712225071193122==--\n" + ) + + result = deserializer.deserialize(value) + + assert result == { + "param1": b"test", + "param2": b"test2", + } + + def test_multipart_form_array(self, deserializer_factory): + mimetype = "multipart/form-data" + schema_dict = { + "type": "object", + "properties": { + "file": { + "type": "array", + "items": {}, + }, + }, + } + schema = SchemaPath.from_dict(schema_dict) + parameters = { + "boundary": "===============2872712225071193122==", + } + deserializer = deserializer_factory( + mimetype, schema=schema, parameters=parameters + ) + value = ( + b"--===============2872712225071193122==\n" + b"Content-Type: text/plain\nMIME-Version: 1.0\n" + b'Content-Disposition: form-data; name="file"\n\ntest\n' + b"--===============2872712225071193122==\n" + b"Content-Type: text/plain\nMIME-Version: 1.0\n" + b'Content-Disposition: form-data; name="file"\n\ntest2\n' + b"--===============2872712225071193122==--\n" + ) + + result = deserializer.deserialize(value) + + assert result == { + "file": [b"test", b"test2"], + } + + def test_custom_simple(self, deserializer_factory): + deserialized = "x-custom" + + def custom_deserializer(value): + return deserialized + + custom_mimetype = "application/custom" + extra_media_type_deserializers = { + custom_mimetype: custom_deserializer, + } + deserializer = deserializer_factory( + custom_mimetype, + extra_media_type_deserializers=extra_media_type_deserializers, + ) + value = b"{}" + + result = deserializer.deserialize( + value, + ) + + assert result == deserialized diff --git a/tests/unit/deserializing/test_styles_deserializers.py b/tests/unit/deserializing/test_styles_deserializers.py new file mode 100644 index 00000000..29e52d25 --- /dev/null +++ b/tests/unit/deserializing/test_styles_deserializers.py @@ -0,0 +1,459 @@ +import pytest +from jsonschema_path import SchemaPath +from werkzeug.datastructures import ImmutableMultiDict + +from openapi_core.deserializing.exceptions import DeserializeError +from openapi_core.deserializing.styles import style_deserializers_factory +from openapi_core.schema.parameters import get_style_and_explode + + +class TestParameterStyleDeserializer: + @pytest.fixture + def deserializer_factory(self): + def create_deserializer(param, name=None): + name = name or param["name"] + style, explode = get_style_and_explode(param) + schema = param / "schema" + return style_deserializers_factory.create( + style, explode, schema, name=name + ) + + return create_deserializer + + @pytest.mark.parametrize( + "location_name", ["cookie", "header", "query", "path"] + ) + @pytest.mark.parametrize("value", ["", "test"]) + def test_unsupported(self, deserializer_factory, location_name, value): + name = "param" + schema_type = "string" + spec = { + "name": name, + "in": location_name, + "style": "unsupported", + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: value} + + with pytest.warns(UserWarning): + result = deserializer.deserialize(location) + + assert result == value + + @pytest.mark.parametrize( + "location_name,style,explode,schema_type,location", + [ + ("query", "matrix", False, "string", {";param": "invalid"}), + ("query", "matrix", False, "array", {";param": "invalid"}), + ("query", "matrix", False, "object", {";param": "invalid"}), + ("query", "matrix", True, "string", {";param*": "invalid"}), + ("query", "deepObject", True, "object", {"param": "invalid"}), + ("query", "form", True, "array", {}), + ], + ) + def test_name_not_found( + self, + deserializer_factory, + location_name, + style, + explode, + schema_type, + location, + ): + name = "param" + spec = { + "name": name, + "in": location_name, + "style": style, + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + + with pytest.raises(KeyError): + deserializer.deserialize(location) + + @pytest.mark.parametrize( + "location_name,style,explode,schema_type,location", + [ + ("path", "deepObject", False, "string", {"param": "invalid"}), + ("path", "deepObject", False, "array", {"param": "invalid"}), + ("path", "deepObject", False, "object", {"param": "invalid"}), + ("path", "deepObject", True, "string", {"param": "invalid"}), + ("path", "deepObject", True, "array", {"param": "invalid"}), + ("path", "spaceDelimited", False, "string", {"param": "invalid"}), + ("path", "pipeDelimited", False, "string", {"param": "invalid"}), + ], + ) + def test_combination_not_available( + self, + deserializer_factory, + location_name, + style, + explode, + schema_type, + location, + ): + name = "param" + spec = { + "name": name, + "in": location_name, + "style": style, + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + + with pytest.raises(DeserializeError): + deserializer.deserialize(location) + + @pytest.mark.parametrize( + "explode,schema_type,location,expected", + [ + (False, "string", {";param": ";param=blue"}, "blue"), + (True, "string", {";param*": ";param=blue"}, "blue"), + ( + False, + "array", + {";param": ";param=blue,black,brown"}, + ["blue", "black", "brown"], + ), + ( + True, + "array", + {";param*": ";param=blue;param=black;param=brown"}, + ["blue", "black", "brown"], + ), + ( + False, + "object", + {";param": ";param=R,100,G,200,B,150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ( + True, + "object", + {";param*": ";R=100;G=200;B=150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_matrix_valid( + self, deserializer_factory, explode, schema_type, location, expected + ): + name = "param" + spec = { + "name": name, + "in": "path", + "style": "matrix", + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + + result = deserializer.deserialize(location) + + assert result == expected + + @pytest.mark.parametrize( + "explode,schema_type,location,expected", + [ + (False, "string", {".param": ".blue"}, "blue"), + (True, "string", {".param*": ".blue"}, "blue"), + ( + False, + "array", + {".param": ".blue,black,brown"}, + ["blue", "black", "brown"], + ), + ( + True, + "array", + {".param*": ".blue.black.brown"}, + ["blue", "black", "brown"], + ), + ( + False, + "object", + {".param": ".R,100,G,200,B,150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ( + True, + "object", + {".param*": ".R=100.G=200.B=150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_label_valid( + self, deserializer_factory, explode, schema_type, location, expected + ): + name = "param" + spec = { + "name": name, + "in": "path", + "style": "label", + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + + result = deserializer.deserialize(location) + + assert result == expected + + @pytest.mark.parametrize("location_name", ["query", "cookie"]) + @pytest.mark.parametrize( + "explode,schema_type,location,expected", + [ + (False, "string", {"param": "blue"}, "blue"), + (True, "string", {"param": "blue"}, "blue"), + ( + False, + "array", + {"param": "blue,black,brown"}, + ["blue", "black", "brown"], + ), + ( + True, + "array", + ImmutableMultiDict( + [("param", "blue"), ("param", "black"), ("param", "brown")] + ), + ["blue", "black", "brown"], + ), + ( + False, + "object", + {"param": "R,100,G,200,B,150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ( + True, + "object", + {"param": "R=100&G=200&B=150"}, + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_form_valid( + self, + deserializer_factory, + location_name, + explode, + schema_type, + location, + expected, + ): + name = "param" + spec = { + "name": name, + "in": location_name, + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + + result = deserializer.deserialize(location) + + assert result == expected + + @pytest.mark.parametrize("location_name", ["path", "header"]) + @pytest.mark.parametrize( + "explode,schema_type,value,expected", + [ + (False, "string", "blue", "blue"), + (True, "string", "blue", "blue"), + (False, "array", "blue,black,brown", ["blue", "black", "brown"]), + (True, "array", "blue,black,brown", ["blue", "black", "brown"]), + ( + False, + "object", + "R,100,G,200,B,150", + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ( + True, + "object", + "R=100,G=200,B=150", + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_simple_valid( + self, + deserializer_factory, + location_name, + explode, + schema_type, + value, + expected, + ): + name = "param" + spec = { + "name": name, + "in": location_name, + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: value} + + result = deserializer.deserialize(location) + + assert result == expected + + @pytest.mark.parametrize( + "schema_type,value,expected", + [ + ("array", "blue%20black%20brown", ["blue", "black", "brown"]), + ( + "object", + "R%20100%20G%20200%20B%20150", + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_space_delimited_valid( + self, deserializer_factory, schema_type, value, expected + ): + name = "param" + spec = { + "name": name, + "in": "query", + "style": "spaceDelimited", + "explode": False, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: value} + + result = deserializer.deserialize(location) + + assert result == expected + + @pytest.mark.parametrize( + "schema_type,value,expected", + [ + ("array", "blue|black|brown", ["blue", "black", "brown"]), + ( + "object", + "R|100|G|200|B|150", + { + "R": "100", + "G": "200", + "B": "150", + }, + ), + ], + ) + def test_pipe_delimited_valid( + self, deserializer_factory, schema_type, value, expected + ): + name = "param" + spec = { + "name": name, + "in": "query", + "style": "pipeDelimited", + "explode": False, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: value} + + result = deserializer.deserialize(location) + + assert result == expected + + def test_deep_object_valid(self, deserializer_factory): + name = "param" + spec = { + "name": name, + "in": "query", + "style": "deepObject", + "explode": True, + "schema": { + "type": "object", + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = { + "param[R]": "100", + "param[G]": "200", + "param[B]": "150", + "other[0]": "value", + } + + result = deserializer.deserialize(location) + + assert result == { + "R": "100", + "G": "200", + "B": "150", + } diff --git a/tests/unit/extensions/test_factories.py b/tests/unit/extensions/test_factories.py new file mode 100644 index 00000000..d50fd551 --- /dev/null +++ b/tests/unit/extensions/test_factories.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from dataclasses import is_dataclass +from sys import modules +from types import ModuleType +from typing import Any + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.extensions.models.factories import ModelPathFactory + + +class TestImportModelCreate: + @pytest.fixture + def loaded_model_class(self): + @dataclass + class BarModel: + a: str + b: int + + foo_module = ModuleType("foo") + foo_module.BarModel = BarModel + modules["foo"] = foo_module + yield BarModel + del modules["foo"] + + def test_dynamic_model(self): + factory = ModelPathFactory() + + schema = SchemaPath.from_dict({"x-model": "TestModel"}) + test_model_class = factory.create(schema, ["name"]) + + assert is_dataclass(test_model_class) + assert test_model_class.__name__ == "TestModel" + assert list(test_model_class.__dataclass_fields__.keys()) == ["name"] + assert test_model_class.__dataclass_fields__["name"].type == str(Any) + + def test_model_path(self, loaded_model_class): + factory = ModelPathFactory() + + schema = SchemaPath.from_dict({"x-model-path": "foo.BarModel"}) + test_model_class = factory.create(schema, ["a", "b"]) + + assert test_model_class == loaded_model_class diff --git a/tests/unit/extensions/test_models.py b/tests/unit/extensions/test_models.py deleted file mode 100644 index 524966d3..00000000 --- a/tests/unit/extensions/test_models.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest - -from openapi_core.extensions.models.models import BaseModel, Model - - -class TestBaseModelDict(object): - - def test_not_implemented(self): - model = BaseModel() - - with pytest.raises(NotImplementedError): - model.__dict__ - - -class TestModelDict(object): - - def test_dict_empty(self): - model = Model() - - result = model.__dict__ - - assert result == {} - - def test_dict(self): - properties = { - 'prop1': 'value1', - 'prop2': 'value2', - } - model = Model(properties) - - result = model.__dict__ - - assert result == properties - - def test_attribute(self): - prop_value = 'value1' - properties = { - 'prop1': prop_value, - } - model = Model(properties) - - result = model.prop1 - - assert result == prop_value diff --git a/tests/unit/schema/test_links.py b/tests/unit/schema/test_links.py deleted file mode 100644 index 346900f7..00000000 --- a/tests/unit/schema/test_links.py +++ /dev/null @@ -1,44 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.links.models import Link -from openapi_core.schema.servers.models import Server - - -class TestLinks(object): - - @pytest.fixture - def link_factory(self): - def link_factory(request_body, server): - parameters = { - 'par1': mock.sentinel.par1, - 'par2': mock.sentinel.par2, - } - return Link( - 'op_id', - parameters, - request_body, - 'Test link', - server - ) - return link_factory - - servers = [ - None, - Server("https://bad.remote.domain.net/"), - Server("http://localhost") - ] - - request_body_list = [ - None, - "request", - '{"request": "value", "opt": 2}', - {"request": "value", "opt": 2} - ] - - @pytest.mark.parametrize("server", servers) - @pytest.mark.parametrize("request_body", request_body_list) - def test_iteritems(self, link_factory, request_body, server): - link = link_factory(request_body, server) - for par_name in link.parameters: - assert link[par_name] == link.parameters[par_name] diff --git a/tests/unit/schema/test_operations.py b/tests/unit/schema/test_operations.py deleted file mode 100644 index b84ce75d..00000000 --- a/tests/unit/schema/test_operations.py +++ /dev/null @@ -1,47 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.operations.models import Operation - - -class TestSchemas(object): - - @pytest.fixture - def operation(self): - parameters = { - 'parameter_1': mock.sentinel.parameter_1, - 'parameter_2': mock.sentinel.parameter_2, - } - return Operation('get', '/path', {}, parameters=parameters) - - def test_iteritems(self, operation): - for name in operation.parameters: - assert operation[name] == operation.parameters[name] - - -class TestResponses(object): - - @pytest.fixture - def operation(self): - responses = { - '200': mock.sentinel.response_200, - '299': mock.sentinel.response_299, - '2XX': mock.sentinel.response_2XX, - 'default': mock.sentinel.response_default, - } - return Operation('get', '/path', responses, parameters={}) - - def test_default(self, operation): - response = operation.get_response() - - assert response == operation.responses['default'] - - def test_range(self, operation): - response = operation.get_response('201') - - assert response == operation.responses['2XX'] - - def test_exact(self, operation): - response = operation.get_response('200') - - assert response == operation.responses['200'] diff --git a/tests/unit/schema/test_parameters.py b/tests/unit/schema/test_parameters.py deleted file mode 100644 index 379bd44c..00000000 --- a/tests/unit/schema/test_parameters.py +++ /dev/null @@ -1,33 +0,0 @@ -from openapi_core.schema.parameters.enums import ParameterStyle -from openapi_core.schema.parameters.models import Parameter - - -class TestParameterInit(object): - - def test_path(self): - param = Parameter('param', 'path') - - assert param.allow_empty_value is False - assert param.style == ParameterStyle.SIMPLE - assert param.explode is False - - def test_query(self): - param = Parameter('param', 'query') - - assert param.allow_empty_value is False - assert param.style == ParameterStyle.FORM - assert param.explode is True - - def test_header(self): - param = Parameter('param', 'header') - - assert param.allow_empty_value is False - assert param.style == ParameterStyle.SIMPLE - assert param.explode is False - - def test_cookie(self): - param = Parameter('param', 'cookie') - - assert param.allow_empty_value is False - assert param.style == ParameterStyle.FORM - assert param.explode is True diff --git a/tests/unit/schema/test_paths.py b/tests/unit/schema/test_paths.py deleted file mode 100644 index bd6c83df..00000000 --- a/tests/unit/schema/test_paths.py +++ /dev/null @@ -1,21 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.paths.models import Path - - -class TestPaths(object): - - @pytest.fixture - def path(self): - operations = { - 'get': mock.sentinel.get, - 'post': mock.sentinel.post, - } - return Path('/path', operations) - - @property - def test_iteritems(self, path): - for http_method in path.operations: - assert path[http_method] ==\ - path.operations[http_method] diff --git a/tests/unit/schema/test_request_bodies.py b/tests/unit/schema/test_request_bodies.py deleted file mode 100644 index cf82eda8..00000000 --- a/tests/unit/schema/test_request_bodies.py +++ /dev/null @@ -1,21 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.request_bodies.models import RequestBody - - -class TestRequestBodies(object): - - @pytest.fixture - def request_body(self): - content = { - 'application/json': mock.sentinel.application_json, - 'text/csv': mock.sentinel.text_csv, - } - return RequestBody(content) - - @property - def test_iteritems(self, request_body): - for mimetype in request_body.content: - assert request_body[mimetype] ==\ - request_body.content[mimetype] diff --git a/tests/unit/schema/test_schema_parameters.py b/tests/unit/schema/test_schema_parameters.py new file mode 100644 index 00000000..3436889c --- /dev/null +++ b/tests/unit/schema/test_schema_parameters.py @@ -0,0 +1,123 @@ +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.schema.parameters import get_explode +from openapi_core.schema.parameters import get_style + + +class TestGetStyle: + @pytest.mark.parametrize( + "location,expected", + [ + ("query", "form"), + ("path", "simple"), + ("header", "simple"), + ("cookie", "form"), + ], + ) + def test_defaults(self, location, expected): + spec = { + "name": "default", + "in": location, + } + param = SchemaPath.from_dict(spec) + result = get_style(param) + + assert result == expected + + @pytest.mark.parametrize( + "style,location", + [ + ("matrix", "path"), + ("label", "apth"), + ("form", "query"), + ("form", "cookie"), + ("simple", "path"), + ("simple", "header"), + ("spaceDelimited", "query"), + ("pipeDelimited", "query"), + ("deepObject", "query"), + ], + ) + def test_defined(self, style, location): + spec = { + "name": "default", + "in": location, + "style": style, + } + param = SchemaPath.from_dict(spec) + result = get_style(param) + + assert result == style + + +class TestGetExplode: + @pytest.mark.parametrize( + "style,location", + [ + ("matrix", "path"), + ("label", "path"), + ("simple", "path"), + ("spaceDelimited", "query"), + ("pipeDelimited", "query"), + ("deepObject", "query"), + ], + ) + def test_defaults_false(self, style, location): + spec = { + "name": "default", + "in": location, + "style": style, + } + param = SchemaPath.from_dict(spec) + result = get_explode(param) + + assert result is False + + @pytest.mark.parametrize("location", ["query", "cookie"]) + def test_defaults_true(self, location): + spec = { + "name": "default", + "in": location, + "style": "form", + } + param = SchemaPath.from_dict(spec) + result = get_explode(param) + + assert result is True + + @pytest.mark.parametrize("location", ["path", "query", "cookie", "header"]) + @pytest.mark.parametrize( + "style", + [ + "matrix", + "label", + "form", + "form", + "simple", + "spaceDelimited", + "pipeDelimited", + "deepObject", + ], + ) + @pytest.mark.parametrize( + "schema_type", + [ + "string", + "array" "object", + ], + ) + @pytest.mark.parametrize("explode", [False, True]) + def test_defined(self, location, style, schema_type, explode): + spec = { + "name": "default", + "in": location, + "explode": explode, + "schema": { + "type": schema_type, + }, + } + param = SchemaPath.from_dict(spec) + result = get_explode(param) + + assert result == explode diff --git a/tests/unit/schema/test_schemas.py b/tests/unit/schema/test_schemas.py deleted file mode 100644 index e9f8b06f..00000000 --- a/tests/unit/schema/test_schemas.py +++ /dev/null @@ -1,20 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.schemas.models import Schema - - -class TestSchemaIteritems(object): - - @pytest.fixture - def schema(self): - properties = { - 'application/json': mock.sentinel.application_json, - 'text/csv': mock.sentinel.text_csv, - } - return Schema('object', properties=properties) - - @property - def test_valid(self, schema): - for name in schema.properties: - assert schema[name] == schema.properties[name] diff --git a/tests/unit/schema/test_schemas_registry.py b/tests/unit/schema/test_schemas_registry.py deleted file mode 100644 index 712032a0..00000000 --- a/tests/unit/schema/test_schemas_registry.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest - -from jsonschema.validators import RefResolver -from openapi_spec_validator.validators import Dereferencer -from openapi_spec_validator import default_handlers - -from openapi_core.schema.schemas.registries import SchemaRegistry - - -class TestSchemaRegistryGetOrCreate(object): - - @pytest.fixture - def schema_dict(self): - return { - 'type': 'object', - 'properties': { - 'message': { - 'type': 'string', - }, - 'suberror': { - '$ref': '#/components/schemas/Error', - }, - }, - } - - @pytest.fixture - def spec_dict(self, schema_dict): - return { - 'components': { - 'schemas': { - 'Error': schema_dict, - }, - }, - } - - @pytest.fixture - def dereferencer(self, spec_dict): - spec_resolver = RefResolver('', spec_dict, handlers=default_handlers) - return Dereferencer(spec_resolver) - - @pytest.fixture - def schemas_registry(self, dereferencer): - return SchemaRegistry(dereferencer) - - def test_recursion(self, schemas_registry, schema_dict): - schema, _ = schemas_registry.get_or_create(schema_dict) - - assert schema.properties['suberror'] ==\ - schema.properties['suberror'].properties['suberror'] diff --git a/tests/unit/schema/test_specs.py b/tests/unit/schema/test_specs.py deleted file mode 100644 index c8a0f0dd..00000000 --- a/tests/unit/schema/test_specs.py +++ /dev/null @@ -1,50 +0,0 @@ -import mock -import pytest - -from openapi_core.schema.operations.exceptions import InvalidOperation -from openapi_core.schema.paths.models import Path -from openapi_core.schema.specs.models import Spec - - -class TestSpecs(object): - - @pytest.fixture - def path1(self): - operations = { - 'get': mock.sentinel.path1_get, - } - return Path('path1', operations) - - @pytest.fixture - def path2(self): - operations = { - 'post': mock.sentinel.path2_psot, - } - return Path('path2', operations) - - @pytest.fixture - def spec(self, path1, path2): - servers = [] - paths = { - '/path1': path1, - '/path2': path2, - } - return Spec(servers, paths) - - def test_iteritems(self, spec): - for path_name in spec.paths: - assert spec[path_name] ==\ - spec.paths[path_name] - - def test_valid(self, spec): - operation = spec.get_operation('/path1', 'get') - - assert operation == mock.sentinel.path1_get - - def test_invalid_path(self, spec): - with pytest.raises(InvalidOperation): - spec.get_operation('/path3', 'get') - - def test_invalid_method(self, spec): - with pytest.raises(InvalidOperation): - spec.get_operation('/path1', 'post') diff --git a/tests/unit/security/test_providers.py b/tests/unit/security/test_providers.py index 057d910b..56f5990f 100644 --- a/tests/unit/security/test_providers.py +++ b/tests/unit/security/test_providers.py @@ -1,37 +1,40 @@ import pytest +from jsonschema_path import SchemaPath -from openapi_core.schema.security_schemes.models import SecurityScheme from openapi_core.security.providers import HttpProvider from openapi_core.testing import MockRequest -class TestHttpProvider(object): - - @pytest.fixture - def scheme(self): - return SecurityScheme('http', scheme='bearer') - - @pytest.fixture - def provider(self, scheme): - return HttpProvider(scheme) - +class TestHttpProvider: @pytest.mark.parametrize( - 'header', - ['authorization', 'Authorization', 'AUTHORIZATION'], + "header", + ["authorization", "Authorization", "AUTHORIZATION"], ) - def test_header(self, provider, header): + @pytest.mark.parametrize( + "scheme", + ["basic", "bearer", "digest"], + ) + def test_header(self, header, scheme): """Tests HttpProvider against Issue29427 https://bugs.python.org/issue29427 """ - jwt = 'MQ' + spec = { + "type": "http", + "scheme": scheme, + } + value = "MQ" headers = { - header: 'Bearer {0}'.format(jwt), + header: " ".join([scheme.title(), value]), } request = MockRequest( - 'http://localhost', 'GET', '/pets', + "http://localhost", + "GET", + "/pets", headers=headers, ) + scheme = SchemaPath.from_dict(spec) + provider = HttpProvider(scheme) - result = provider(request) + result = provider(request.parameters) - assert result == jwt + assert result == value diff --git a/tests/unit/templating/test_media_types_finders.py b/tests/unit/templating/test_media_types_finders.py new file mode 100644 index 00000000..d83cc1f1 --- /dev/null +++ b/tests/unit/templating/test_media_types_finders.py @@ -0,0 +1,64 @@ +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.templating.media_types.exceptions import MediaTypeNotFound +from openapi_core.templating.media_types.finders import MediaTypeFinder + + +class TestMediaTypes: + @pytest.fixture(scope="class") + def spec(self): + return { + "application/json": {"schema": {"type": "object"}}, + "text/*": {"schema": {"type": "object"}}, + } + + @pytest.fixture(scope="class") + def content(self, spec): + return SchemaPath.from_dict(spec) + + @pytest.fixture(scope="class") + def finder(self, content): + return MediaTypeFinder(content) + + @pytest.mark.parametrize( + "media_type", + [ + # equivalent according to RFC 9110 + "text/html;charset=utf-8", + 'Text/HTML;Charset="utf-8"', + 'text/html; charset="utf-8"', + "text/html;charset=UTF-8", + "text/html ; charset=utf-8", + ], + ) + def test_charset(self, finder, content, media_type): + mimetype, parameters, _ = finder.find(media_type) + assert mimetype == "text/*" + assert parameters == {"charset": "utf-8"} + + def test_exact(self, finder, content): + mimetype = "application/json" + + mimetype, parameters, _ = finder.find(mimetype) + assert mimetype == "application/json" + assert parameters == {} + + def test_match(self, finder, content): + mimetype = "text/html" + + mimetype, parameters, _ = finder.find(mimetype) + assert mimetype == "text/*" + assert parameters == {} + + def test_not_found(self, finder, content): + mimetype = "unknown" + + with pytest.raises(MediaTypeNotFound): + finder.find(mimetype) + + def test_missing(self, finder, content): + mimetype = None + + with pytest.raises(MediaTypeNotFound): + finder.find(mimetype) diff --git a/tests/unit/templating/test_paths_finders.py b/tests/unit/templating/test_paths_finders.py index 3b95aa0d..63505a48 100644 --- a/tests/unit/templating/test_paths_finders.py +++ b/tests/unit/templating/test_paths_finders.py @@ -1,65 +1,67 @@ import pytest +from jsonschema_path import SchemaPath -from openapi_core.schema.infos.models import Info -from openapi_core.schema.operations.models import Operation -from openapi_core.schema.parameters.models import Parameter -from openapi_core.schema.paths.models import Path -from openapi_core.schema.servers.models import Server, ServerVariable -from openapi_core.schema.specs.models import Spec from openapi_core.templating.datatypes import TemplateResult -from openapi_core.templating.paths.exceptions import ( - PathNotFound, OperationNotFound, ServerNotFound, -) -from openapi_core.templating.paths.finders import PathFinder -from openapi_core.testing import MockRequest +from openapi_core.templating.paths.exceptions import OperationNotFound +from openapi_core.templating.paths.exceptions import PathNotFound +from openapi_core.templating.paths.exceptions import PathsNotFound +from openapi_core.templating.paths.exceptions import ServerNotFound +from openapi_core.templating.paths.finders import APICallPathFinder -class BaseTestSimpleServer(object): +class BaseTestSimpleServer: + server_url = "http://petstore.swagger.io" - server_url = 'http://petstore.swagger.io' + @pytest.fixture + def server_variable(self): + return {} + + @pytest.fixture + def server_variables(self, server_variable): + if not server_variable: + return {} + return { + self.server_variable_name: server_variable, + } @pytest.fixture - def server(self): - return Server(self.server_url, {}) + def server(self, server_variables): + server = { + "url": self.server_url, + } + if server_variables: + server["variables"] = server_variables + return server @pytest.fixture def servers(self, server): - return [server, ] + return [ + server, + ] class BaseTestVariableServer(BaseTestSimpleServer): - - server_url = 'http://petstore.swagger.io/{version}' - server_variable_name = 'version' - server_variable_default = 'v1' - server_variable_enum = ['v1', 'v2'] + server_url = "http://petstore.swagger.io/{version}" + server_variable_name = "version" + server_variable_default = "v1" + server_variable_enum = ["v1", "v2"] @pytest.fixture def server_variable(self): - return ServerVariable( - self.server_variable_name, - default=self.server_variable_default, - enum=self.server_variable_enum, - ) - - @pytest.fixture - def server_variables(self, server_variable): return { - self.server_variable_name: server_variable, + self.server_variable_name: { + "default": self.server_variable_default, + "enum": self.server_variable_enum, + } } - @pytest.fixture - def server(self, server_variables): - return Server(self.server_url, server_variables) - - -class BaseTestSimplePath(object): - path_name = '/resource' +class BaseTestSimplePath: + path_name = "/resource" @pytest.fixture def path(self, operations): - return Path(self.path_name, operations) + return operations @pytest.fixture def paths(self, path): @@ -69,456 +71,738 @@ def paths(self, path): class BaseTestVariablePath(BaseTestSimplePath): - - path_name = '/resource/{resource_id}' - path_parameter_name = 'resource_id' + path_name = "/resource/{resource_id}" + path_parameter_name = "resource_id" @pytest.fixture def parameter(self): - return Parameter(self.path_parameter_name, 'path') + return { + "name": self.path_parameter_name, + "in": "path", + } @pytest.fixture def parameters(self, parameter): - return { - self.path_parameter_name: parameter - } + return [ + parameter, + ] @pytest.fixture def path(self, operations, parameters): - return Path(self.path_name, operations, parameters=parameters) + path = operations.copy() + path["parameters"] = parameters + return path -class BaseTestSpecServer(object): +class BaseTestSpecServer: + location = "spec" @pytest.fixture def info(self): - return Info('Test schema', '1.0') + return { + "title": "Test schema", + "version": "1.0", + } @pytest.fixture def operation(self): - return Operation('get', self.path_name, {}, {}) + return { + "responses": [], + } @pytest.fixture def operations(self, operation): return { - 'get': operation, + "get": operation, } @pytest.fixture def spec(self, info, paths, servers): - return Spec(info, paths, servers) + spec = { + "info": info, + "servers": servers, + "paths": paths, + } + return SchemaPath.from_dict(spec) @pytest.fixture def finder(self, spec): - return PathFinder(spec) + return APICallPathFinder(spec) class BaseTestPathServer(BaseTestSpecServer): + location = "path" @pytest.fixture def path(self, operations, servers): - return Path(self.path_name, operations, servers=servers) + path = operations.copy() + path["servers"] = servers + return path @pytest.fixture def spec(self, info, paths): - return Spec(info, paths) + spec = { + "info": info, + "paths": paths, + } + return SchemaPath.from_dict(spec) class BaseTestOperationServer(BaseTestSpecServer): + location = "operation" @pytest.fixture def operation(self, servers): - return Operation('get', self.path_name, {}, {}, servers=servers) + return { + "responses": [], + "servers": servers, + } @pytest.fixture def spec(self, info, paths): - return Spec(info, paths) - + spec = { + "info": info, + "paths": paths, + } + return SchemaPath.from_dict(spec) -class BaseTestServerNotFound(object): +class BaseTestServerNotFound: @pytest.fixture def servers(self): - return [] + return [ + SchemaPath.from_dict( + {"url": "http://petstore.swagger.io/resource"} + ) + ] def test_raises(self, finder): - request_uri = '/resource' - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) + method = "get" + full_url = "http://invalidserver/resource" with pytest.raises(ServerNotFound): - finder.find(request) + finder.find(method, full_url) + + +class BaseTestDefaultServer: + @pytest.fixture + def servers(self): + return [] + + def test_returns_default_server(self, finder, spec): + method = "get" + full_url = "http://petstore.swagger.io/resource" + + result = finder.find(method, full_url) + path = spec / "paths" / self.path_name + operation = spec / "paths" / self.path_name / method + server = SchemaPath.from_dict({"url": "/"}) + path_result = TemplateResult(self.path_name, {}) + server_result = TemplateResult("/", {}) + assert result == ( + path, + operation, + server, + path_result, + server_result, + ) -class BaseTestOperationNotFound(object): +class BaseTestOperationNotFound: @pytest.fixture def operations(self): return {} def test_raises(self, finder): - request_uri = '/resource' - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) + method = "get" + full_url = "http://petstore.swagger.io/resource" with pytest.raises(OperationNotFound): - finder.find(request) + finder.find(method, full_url) -class BaseTestValid(object): +class BaseTestValid: + def test_simple(self, finder, spec): + method = "get" + full_url = "http://petstore.swagger.io/resource" - def test_simple(self, finder, path, operation, server): - request_uri = '/resource' - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) - - result = finder.find(request) + result = finder.find(method, full_url) + path = spec / "paths" / self.path_name + operation = spec / "paths" / self.path_name / method + server = eval(self.location) / "servers" / 0 path_result = TemplateResult(self.path_name, {}) server_result = TemplateResult(self.server_url, {}) assert result == ( - path, operation, server, path_result, server_result, + path, + operation, + server, + path_result, + server_result, ) -class BaseTestVariableValid(object): - - @pytest.mark.parametrize('version', ['v1', 'v2']) - def test_variable(self, finder, path, operation, server, version): - request_uri = '/{0}/resource'.format(version) - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) +class BaseTestVariableValid: + @pytest.mark.parametrize("version", ["v1", "v2", ""]) + def test_variable(self, finder, spec, version): + method = "get" + full_url = f"http://petstore.swagger.io/{version}/resource" - result = finder.find(request) + result = finder.find(method, full_url) + path = spec / "paths" / self.path_name + operation = spec / "paths" / self.path_name / method + server = eval(self.location) / "servers" / 0 path_result = TemplateResult(self.path_name, {}) - server_result = TemplateResult(self.server_url, {'version': version}) + server_result = TemplateResult(self.server_url, {"version": version}) assert result == ( - path, operation, server, path_result, server_result, + path, + operation, + server, + path_result, + server_result, ) -class BaseTestPathVariableValid(object): - - @pytest.mark.parametrize('res_id', ['111', '222']) - def test_path_variable(self, finder, path, operation, server, res_id): - request_uri = '/resource/{0}'.format(res_id) - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) +class BaseTestPathVariableValid: + @pytest.mark.parametrize("res_id", ["111", "222"]) + def test_path_variable(self, finder, spec, res_id): + method = "get" + full_url = f"http://petstore.swagger.io/resource/{res_id}" - result = finder.find(request) + result = finder.find(method, full_url) - path_result = TemplateResult(self.path_name, {'resource_id': res_id}) + path = spec / "paths" / self.path_name + operation = spec / "paths" / self.path_name / method + server = eval(self.location) / "servers" / 0 + path_result = TemplateResult(self.path_name, {"resource_id": res_id}) server_result = TemplateResult(self.server_url, {}) assert result == ( - path, operation, server, path_result, server_result, + path, + operation, + server, + path_result, + server_result, ) -class BaseTestPathNotFound(object): - +class BaseTestPathNotFound: @pytest.fixture def paths(self): return {} def test_raises(self, finder): - request_uri = '/resource' - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) + method = "get" + full_url = "http://petstore.swagger.io/resource" with pytest.raises(PathNotFound): - finder.find(request) + finder.find(method, full_url) + + +class BaseTestPathsNotFound: + @pytest.fixture + def spec(self, info): + spec = { + "info": info, + } + return SchemaPath.from_dict(spec) + + def test_raises(self, finder): + method = "get" + full_url = "http://petstore.swagger.io/resource" + + with pytest.raises(PathsNotFound): + finder.find(method, full_url) + + +class TestSpecSimpleServerDefaultServer( + BaseTestDefaultServer, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): + pass class TestSpecSimpleServerServerNotFound( - BaseTestServerNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestServerNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestSpecSimpleServerOperationNotFound( - BaseTestOperationNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestOperationNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestSpecSimpleServerPathNotFound( - BaseTestPathNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestPathNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): + pass + + +class TestSpecSimpleServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestSpecServer, + BaseTestSimpleServer, +): + pass + + +class TestOperationSimpleServerDefaultServer( + BaseTestDefaultServer, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestOperationSimpleServerServerNotFound( - BaseTestServerNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestServerNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestOperationSimpleServerOperationNotFound( - BaseTestOperationNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestOperationNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestOperationSimpleServerPathNotFound( - BaseTestPathNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestPathNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): + pass + + +class TestOperationSimpleServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestOperationServer, + BaseTestSimpleServer, +): + pass + + +class TestPathSimpleServerDefaultServer( + BaseTestDefaultServer, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestPathSimpleServerServerNotFound( - BaseTestServerNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestServerNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestPathSimpleServerOperationNotFound( - BaseTestOperationNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestOperationNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestPathSimpleServerPathNotFound( - BaseTestPathNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestPathNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): + pass + + +class TestPathSimpleServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestPathServer, + BaseTestSimpleServer, +): pass class TestSpecSimpleServerValid( - BaseTestValid, BaseTestSpecServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestValid, BaseTestSpecServer, BaseTestSimplePath, BaseTestSimpleServer +): pass class TestOperationSimpleServerValid( - BaseTestValid, BaseTestOperationServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestValid, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestSimpleServer, +): pass class TestPathSimpleServerValid( - BaseTestValid, BaseTestPathServer, - BaseTestSimplePath, BaseTestSimpleServer): + BaseTestValid, BaseTestPathServer, BaseTestSimplePath, BaseTestSimpleServer +): pass class TestSpecSimpleServerVariablePathValid( - BaseTestPathVariableValid, BaseTestSpecServer, - BaseTestVariablePath, BaseTestSimpleServer): + BaseTestPathVariableValid, + BaseTestSpecServer, + BaseTestVariablePath, + BaseTestSimpleServer, +): pass class TestOperationSimpleServerVariablePathValid( - BaseTestPathVariableValid, BaseTestOperationServer, - BaseTestVariablePath, BaseTestSimpleServer): + BaseTestPathVariableValid, + BaseTestOperationServer, + BaseTestVariablePath, + BaseTestSimpleServer, +): pass class TestPathSimpleServerVariablePathValid( - BaseTestPathVariableValid, BaseTestPathServer, - BaseTestVariablePath, BaseTestSimpleServer): + BaseTestPathVariableValid, + BaseTestPathServer, + BaseTestVariablePath, + BaseTestSimpleServer, +): + pass + + +class TestSpecVariableServerDefaultServer( + BaseTestDefaultServer, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestSpecVariableServerServerNotFound( - BaseTestServerNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestServerNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestSpecVariableServerOperationNotFound( - BaseTestOperationNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestOperationNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestSpecVariableServerPathNotFound( - BaseTestPathNotFound, BaseTestSpecServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestPathNotFound, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestVariableServer, +): + pass + + +class TestSpecVariableServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestSpecServer, + BaseTestVariableServer, +): + pass + + +class TestOperationVariableServerDefaultServer( + BaseTestDefaultServer, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestOperationVariableServerServerNotFound( - BaseTestServerNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestServerNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestOperationVariableServerOperationNotFound( - BaseTestOperationNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestOperationNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestOperationVariableServerPathNotFound( - BaseTestPathNotFound, BaseTestOperationServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestPathNotFound, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestVariableServer, +): + pass + + +class TestOperationVariableServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestOperationServer, + BaseTestVariableServer, +): + pass + + +class TestPathVariableServerDefaultServer( + BaseTestDefaultServer, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestPathVariableServerServerNotFound( - BaseTestServerNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestServerNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestPathVariableServerOperationNotFound( - BaseTestOperationNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestOperationNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestPathVariableServerPathNotFound( - BaseTestPathNotFound, BaseTestPathServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestPathNotFound, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestVariableServer, +): + pass + + +class TestPathVariableServerPathsNotFound( + BaseTestPathsNotFound, + BaseTestPathServer, + BaseTestVariableServer, +): pass class TestSpecVariableServerValid( - BaseTestVariableValid, BaseTestSpecServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestVariableValid, + BaseTestSpecServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestOperationVariableServerValid( - BaseTestVariableValid, BaseTestOperationServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestVariableValid, + BaseTestOperationServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass class TestPathVariableServerValid( - BaseTestVariableValid, BaseTestPathServer, - BaseTestSimplePath, BaseTestVariableServer): + BaseTestVariableValid, + BaseTestPathServer, + BaseTestSimplePath, + BaseTestVariableServer, +): pass -class TestSimilarPaths( - BaseTestSpecServer, BaseTestSimpleServer): - - path_name = '/tokens' +class TestSimilarPaths(BaseTestSpecServer, BaseTestSimpleServer): + path_name = "/tokens" + path_2_name = "/keys/{id}/tokens" @pytest.fixture def operation_2(self): - return Operation('get', '/keys/{id}/tokens', {}, {}) + return { + "responses": [], + } @pytest.fixture def operations_2(self, operation_2): return { - 'get': operation_2, + "get": operation_2, } @pytest.fixture def path(self, operations): - return Path('/tokens', operations) + return operations @pytest.fixture def path_2(self, operations_2): - return Path('/keys/{id}/tokens', operations_2) + return operations_2 @pytest.fixture def paths(self, path, path_2): return { - path.name: path, - path_2.name: path_2, + self.path_name: path, + self.path_2_name: path_2, } - def test_valid(self, finder, path_2, operation_2, server): - token_id = '123' - request_uri = '/keys/{0}/tokens'.format(token_id) - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) + def test_valid(self, finder, spec): + token_id = "123" + method = "get" + full_url = f"http://petstore.swagger.io/keys/{token_id}/tokens" - result = finder.find(request) + result = finder.find(method, full_url) - path_result = TemplateResult(path_2.name, {'id': token_id}) + path_2 = spec / "paths" / self.path_2_name + operation_2 = spec / "paths" / self.path_2_name / method + server = eval(self.location) / "servers" / 0 + path_result = TemplateResult(self.path_2_name, {"id": token_id}) server_result = TemplateResult(self.server_url, {}) assert result == ( - path_2, operation_2, server, path_result, server_result, + path_2, + operation_2, + server, + path_result, + server_result, ) -class TestConcretePaths( - BaseTestSpecServer, BaseTestSimpleServer): - - path_name = '/keys/{id}/tokens' +class TestConcretePaths(BaseTestSpecServer, BaseTestSimpleServer): + path_name = "/keys/{id}/tokens" + path_2_name = "/keys/master/tokens" @pytest.fixture def operation_2(self): - return Operation('get', '/keys/master/tokens', {}, {}) + return { + "responses": [], + } @pytest.fixture def operations_2(self, operation_2): return { - 'get': operation_2, + "get": operation_2, } @pytest.fixture def path(self, operations): - return Path('/keys/{id}/tokens', operations) + return operations @pytest.fixture def path_2(self, operations_2): - return Path('/keys/master/tokens', operations_2) + return operations_2 @pytest.fixture def paths(self, path, path_2): return { - path.name: path, - path_2.name: path_2, + self.path_name: path, + self.path_2_name: path_2, } - def test_valid(self, finder, path_2, operation_2, server): - request_uri = '/keys/master/tokens' - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) - result = finder.find(request) + def test_valid(self, finder, spec): + method = "get" + full_url = "http://petstore.swagger.io/keys/master/tokens" + result = finder.find(method, full_url) - path_result = TemplateResult(path_2.name, {}) + path_2 = spec / "paths" / self.path_2_name + operation_2 = spec / "paths" / self.path_2_name / method + server = eval(self.location) / "servers" / 0 + path_result = TemplateResult(self.path_2_name, {}) server_result = TemplateResult(self.server_url, {}) assert result == ( - path_2, operation_2, server, path_result, server_result, + path_2, + operation_2, + server, + path_result, + server_result, ) -class TestTemplateConcretePaths( - BaseTestSpecServer, BaseTestSimpleServer): - - path_name = '/keys/{id}/tokens/{id2}' +class TestTemplateConcretePaths(BaseTestSpecServer, BaseTestSimpleServer): + path_name = "/keys/{id}/tokens/{id2}" + path_2_name = "/keys/{id}/tokens/master" @pytest.fixture def operation_2(self): - return Operation('get', '/keys/{id}/tokens/master', {}, {}) + return { + "responses": [], + } @pytest.fixture def operations_2(self, operation_2): return { - 'get': operation_2, + "get": operation_2, } @pytest.fixture def path(self, operations): - return Path('/keys/{id}/tokens/{id2}', operations) + return operations @pytest.fixture def path_2(self, operations_2): - return Path('/keys/{id}/tokens/master', operations_2) + return operations_2 @pytest.fixture def paths(self, path, path_2): return { - path.name: path, - path_2.name: path_2, + self.path_name: path, + self.path_2_name: path_2, } - def test_valid(self, finder, path_2, operation_2, server): - token_id = '123' - request_uri = '/keys/{0}/tokens/master'.format(token_id) - request = MockRequest( - 'http://petstore.swagger.io', 'get', request_uri) - result = finder.find(request) + def test_valid(self, finder, spec): + token_id = "123" + method = "get" + full_url = f"http://petstore.swagger.io/keys/{token_id}/tokens/master" + result = finder.find(method, full_url) - path_result = TemplateResult(path_2.name, {'id': '123'}) + path_2 = spec / "paths" / self.path_2_name + operation_2 = spec / "paths" / self.path_2_name / method + server = eval(self.location) / "servers" / 0 + path_result = TemplateResult(self.path_2_name, {"id": "123"}) server_result = TemplateResult(self.server_url, {}) assert result == ( - path_2, operation_2, server, path_result, server_result, + path_2, + operation_2, + server, + path_result, + server_result, ) diff --git a/tests/unit/templating/test_responses_finders.py b/tests/unit/templating/test_responses_finders.py new file mode 100644 index 00000000..5aac4fbc --- /dev/null +++ b/tests/unit/templating/test_responses_finders.py @@ -0,0 +1,40 @@ +from unittest import mock + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.templating.responses.finders import ResponseFinder + + +class TestResponses: + @pytest.fixture(scope="class") + def spec(self): + return { + "200": mock.sentinel.response_200, + "299": mock.sentinel.response_299, + "2XX": mock.sentinel.response_2XX, + "default": mock.sentinel.response_default, + } + + @pytest.fixture(scope="class") + def responses(self, spec): + return SchemaPath.from_dict(spec) + + @pytest.fixture(scope="class") + def finder(self, responses): + return ResponseFinder(responses) + + def test_default(self, finder, responses): + response = finder.find() + + assert response == responses / "default" + + def test_range(self, finder, responses): + response = finder.find("201") + + assert response == responses / "2XX" + + def test_exact(self, finder, responses): + response = finder.find("200") + + assert response == responses / "200" diff --git a/tests/unit/templating/test_templating_util.py b/tests/unit/templating/test_templating_util.py new file mode 100644 index 00000000..815f6cb0 --- /dev/null +++ b/tests/unit/templating/test_templating_util.py @@ -0,0 +1,60 @@ +import pytest + +from openapi_core.templating.util import search + + +class TestSearch: + def test_endswith(self): + path_pattern = "/{test}/test" + full_url_pattern = "/test1/test/test2/test" + + result = search(path_pattern, full_url_pattern) + + assert result.named == { + "test": "test2", + } + + def test_exact(self): + path_pattern = "/{test}/test" + full_url_pattern = "/test/test" + + result = search(path_pattern, full_url_pattern) + + assert result.named == { + "test": "test", + } + + @pytest.mark.parametrize( + "path_pattern,expected", + [ + ("/{test_id}/test", {"test_id": "test"}), + ("/{test.id}/test", {"test.id": "test"}), + ("/{test-id}/test", {"test-id": "test"}), + ], + ) + def test_chars_valid(self, path_pattern, expected): + full_url_pattern = "/test/test" + + result = search(path_pattern, full_url_pattern) + + assert result.named == expected + + @pytest.mark.xfail( + reason=( + "Special characters of regex not supported. " + "See https://github.com/python-openapi/openapi-core/issues/672" + ), + strict=True, + ) + @pytest.mark.parametrize( + "path_pattern,expected", + [ + ("/{test~id}/test", {"test~id": "test"}), + ], + ) + def test_special_chars_valid(self, path_pattern, expected): + full_url_pattern = "/test/test" + + result = search(path_pattern, full_url_pattern) + + assert result.named == expected diff --git a/tests/unit/templating/test_util.py b/tests/unit/templating/test_util.py deleted file mode 100644 index bd695409..00000000 --- a/tests/unit/templating/test_util.py +++ /dev/null @@ -1,24 +0,0 @@ -from openapi_core.templating.util import search - - -class TestSearch: - - def test_endswith(self): - path_patter = '/{test}/test' - full_url_pattern = '/test1/test/test2/test' - - result = search(path_patter, full_url_pattern) - - assert result.named == { - 'test': 'test2', - } - - def test_exact(self): - path_patter = '/{test}/test' - full_url_pattern = '/test/test' - - result = search(path_patter, full_url_pattern) - - assert result.named == { - 'test': 'test', - } diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 00000000..a98f7a8b --- /dev/null +++ b/tests/unit/test_app.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import pytest + +from openapi_core import Config +from openapi_core import OpenAPI +from openapi_core.exceptions import SpecError + + +class TestOpenAPIFromPath: + def test_valid(self, create_file): + spec_dict = { + "openapi": "3.1.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + file_path = create_file(spec_dict) + path = Path(file_path) + result = OpenAPI.from_path(path) + + assert type(result) == OpenAPI + assert result.spec.contents() == spec_dict + + +class TestOpenAPIFromFilePath: + def test_valid(self, create_file): + spec_dict = { + "openapi": "3.1.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + file_path = create_file(spec_dict) + result = OpenAPI.from_file_path(file_path) + + assert type(result) == OpenAPI + assert result.spec.contents() == spec_dict + + +class TestOpenAPIFromFile: + def test_valid(self, create_file): + spec_dict = { + "openapi": "3.1.0", + "info": { + "title": "Spec", + "version": "0.0.1", + }, + "paths": {}, + } + file_path = create_file(spec_dict) + with open(file_path) as f: + result = OpenAPI.from_file(f) + + assert type(result) == OpenAPI + assert result.spec.contents() == spec_dict + + +class TestOpenAPIFromDict: + def test_spec_error(self): + spec_dict = {} + + with pytest.raises(SpecError): + OpenAPI.from_dict(spec_dict) + + def test_check_skipped(self): + spec_dict = {} + config = Config(spec_validator_cls=None) + + result = OpenAPI.from_dict(spec_dict, config=config) + + assert type(result) == OpenAPI + assert result.spec.contents() == spec_dict diff --git a/tests/unit/test_paths_spec.py b/tests/unit/test_paths_spec.py new file mode 100644 index 00000000..8167abf3 --- /dev/null +++ b/tests/unit/test_paths_spec.py @@ -0,0 +1,11 @@ +import pytest + +from openapi_core import Spec + + +class TestSpecFromDict: + def test_deprecated(self): + schema = {} + + with pytest.warns(DeprecationWarning): + Spec.from_dict(schema) diff --git a/tests/unit/test_shortcuts.py b/tests/unit/test_shortcuts.py new file mode 100644 index 00000000..9a3f36c9 --- /dev/null +++ b/tests/unit/test_shortcuts.py @@ -0,0 +1,1014 @@ +from unittest import mock + +import pytest +from openapi_spec_validator import OpenAPIV31SpecValidator + +from openapi_core import unmarshal_apicall_request +from openapi_core import unmarshal_apicall_response +from openapi_core import unmarshal_request +from openapi_core import unmarshal_response +from openapi_core import unmarshal_webhook_request +from openapi_core import unmarshal_webhook_response +from openapi_core import validate_apicall_request +from openapi_core import validate_apicall_response +from openapi_core import validate_request +from openapi_core import validate_response +from openapi_core import validate_webhook_request +from openapi_core import validate_webhook_response +from openapi_core.exceptions import SpecError +from openapi_core.protocols import Request +from openapi_core.protocols import Response +from openapi_core.protocols import WebhookRequest +from openapi_core.testing.datatypes import ResultMock +from openapi_core.unmarshalling.request.datatypes import RequestUnmarshalResult +from openapi_core.unmarshalling.request.unmarshallers import ( + APICallRequestUnmarshaller, +) +from openapi_core.unmarshalling.request.unmarshallers import ( + WebhookRequestUnmarshaller, +) +from openapi_core.unmarshalling.response.datatypes import ( + ResponseUnmarshalResult, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + APICallResponseUnmarshaller, +) +from openapi_core.unmarshalling.response.unmarshallers import ( + WebhookResponseUnmarshaller, +) +from openapi_core.validation.request.validators import APICallRequestValidator +from openapi_core.validation.request.validators import WebhookRequestValidator +from openapi_core.validation.response.validators import ( + APICallResponseValidator, +) +from openapi_core.validation.response.validators import ( + WebhookResponseValidator, +) + + +class MockClass: + spec_validator_cls = None + schema_casters_factory = None + schema_validators_factory = None + schema_unmarshallers_factory = None + + unmarshal_calls = [] + validate_calls = [] + return_unmarshal = None + + @classmethod + def setUp(cls, return_unmarshal=None): + cls.unmarshal_calls = [] + cls.validate_calls = [] + cls.return_unmarshal = return_unmarshal + + +class MockReqValidator(MockClass): + def validate(self, req): + self.validate_calls.append((req,)) + + +class MockReqUnmarshaller(MockClass): + def unmarshal(self, req): + self.unmarshal_calls.append((req,)) + return self.return_unmarshal + + +class MockRespValidator(MockClass): + def validate(self, req, resp): + self.validate_calls.append((req, resp)) + + +class MockRespUnmarshaller(MockClass): + def unmarshal(self, req, resp): + self.unmarshal_calls.append((req, resp)) + return self.return_unmarshal + + +@pytest.fixture(autouse=True) +def setup(): + MockClass.setUp() + yield + + +class TestUnmarshalAPICallRequest: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + unmarshal_apicall_request(request, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + unmarshal_apicall_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + unmarshal_apicall_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_apicall_request(request, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + + with pytest.raises(TypeError): + unmarshal_apicall_request(request, spec=spec_v31, cls=Exception) + + +class TestUnmarshalWebhookRequest: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + unmarshal_webhook_request(request, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + unmarshal_webhook_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + unmarshal_webhook_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=WebhookRequest) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_webhook_request(request, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(TypeError): + unmarshal_webhook_request(request, spec=spec_v31, cls=Exception) + + def test_spec_oas30_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + unmarshal_webhook_request(request, spec=spec_v30) + + @mock.patch( + "openapi_core.unmarshalling.request.unmarshallers.WebhookRequestUnmarshaller." + "unmarshal", + ) + def test_request(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=WebhookRequest) + + result = unmarshal_webhook_request(request, spec=spec_v31) + + assert result == mock_unmarshal.return_value + mock_unmarshal.assert_called_once_with(request) + + +class TestUnmarshalRequest: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + unmarshal_request(request, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + unmarshal_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + unmarshal_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_request(request, spec=spec) + + def test_cls_apicall_unmarshaller(self, spec_v31): + request = mock.Mock(spec=Request) + unmarshal = mock.Mock(spec=RequestUnmarshalResult) + TestAPICallReq = type( + "TestAPICallReq", + (MockReqUnmarshaller, APICallRequestUnmarshaller), + {}, + ) + TestAPICallReq.setUp(unmarshal) + + result = unmarshal_request(request, spec=spec_v31, cls=TestAPICallReq) + + assert result == unmarshal + assert TestAPICallReq.unmarshal_calls == [ + (request,), + ] + + def test_cls_webhook_unmarshaller(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + unmarshal = mock.Mock(spec=RequestUnmarshalResult) + TestWebhookReq = type( + "TestWebhookReq", + (MockReqUnmarshaller, WebhookRequestUnmarshaller), + {}, + ) + TestWebhookReq.setUp(unmarshal) + + result = unmarshal_request(request, spec=spec_v31, cls=TestWebhookReq) + + assert result == unmarshal + assert TestWebhookReq.unmarshal_calls == [ + (request,), + ] + + @pytest.mark.parametrize("req", [Request, WebhookRequest]) + def test_cls_type_invalid(self, spec_v31, req): + request = mock.Mock(spec=req) + + with pytest.raises(TypeError): + unmarshal_request(request, spec=spec_v31, cls=Exception) + + @mock.patch( + "openapi_core.unmarshalling.request.unmarshallers.APICallRequestUnmarshaller." + "unmarshal", + ) + def test_request(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=Request) + + result = unmarshal_request(request, spec=spec_v31) + + assert result == mock_unmarshal.return_value + mock_unmarshal.assert_called_once_with(request) + + @mock.patch( + "openapi_core.unmarshalling.request.unmarshallers.APICallRequestUnmarshaller." + "unmarshal", + ) + def test_request_error(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=Request) + mock_unmarshal.return_value = ResultMock(error_to_raise=ValueError) + + with pytest.raises(ValueError): + unmarshal_request(request, spec=spec_v31) + + mock_unmarshal.assert_called_once_with(request) + + +class TestUnmarshalAPICallResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_apicall_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_apicall_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_apicall_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.sentinel.response + + with pytest.raises(TypeError): + unmarshal_apicall_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_apicall_response(request, response, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_apicall_response( + request, response, spec=spec_v31, cls=Exception + ) + + +class TestUnmarshalResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.sentinel.response + + with pytest.raises(TypeError): + unmarshal_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_response(request, response, spec=spec) + + def test_cls_apicall_unmarshaller(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + unmarshal = mock.Mock(spec=ResponseUnmarshalResult) + TestAPICallReq = type( + "TestAPICallReq", + (MockRespUnmarshaller, APICallResponseUnmarshaller), + {}, + ) + TestAPICallReq.setUp(unmarshal) + + result = unmarshal_response( + request, response, spec=spec_v31, cls=TestAPICallReq + ) + + assert result == unmarshal + assert TestAPICallReq.unmarshal_calls == [ + (request, response), + ] + + def test_cls_webhook_unmarshaller(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + unmarshal = mock.Mock(spec=ResponseUnmarshalResult) + TestWebhookReq = type( + "TestWebhookReq", + (MockRespUnmarshaller, WebhookResponseUnmarshaller), + {}, + ) + TestWebhookReq.setUp(unmarshal) + + result = unmarshal_response( + request, response, spec=spec_v31, cls=TestWebhookReq + ) + + assert result == unmarshal + assert TestWebhookReq.unmarshal_calls == [ + (request, response), + ] + + @pytest.mark.parametrize("req", [Request, WebhookRequest]) + def test_cls_type_invalid(self, spec_v31, req): + request = mock.Mock(spec=req) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_response(request, response, spec=spec_v31, cls=Exception) + + @mock.patch( + "openapi_core.unmarshalling.response.unmarshallers.APICallResponseUnmarshaller." + "unmarshal", + ) + def test_request_response(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + result = unmarshal_response(request, response, spec=spec_v31) + + assert result == mock_unmarshal.return_value + mock_unmarshal.assert_called_once_with(request, response) + + @mock.patch( + "openapi_core.unmarshalling.response.unmarshallers.APICallResponseUnmarshaller." + "unmarshal", + ) + def test_request_response_error(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + mock_unmarshal.return_value = ResultMock(error_to_raise=ValueError) + + with pytest.raises(ValueError): + unmarshal_response(request, response, spec=spec_v31) + + mock_unmarshal.assert_called_once_with(request, response) + + +class TestUnmarshalWebhookResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_webhook_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_webhook_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_webhook_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.sentinel.response + + with pytest.raises(TypeError): + unmarshal_webhook_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + unmarshal_webhook_response(request, response, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + unmarshal_webhook_response( + request, response, spec=spec_v31, cls=Exception + ) + + def test_spec_oas30_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + unmarshal_webhook_response(request, response, spec=spec_v30) + + @mock.patch( + "openapi_core.unmarshalling.response.unmarshallers.WebhookResponseUnmarshaller." + "unmarshal", + ) + def test_request_response(self, mock_unmarshal, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + result = unmarshal_webhook_response(request, response, spec=spec_v31) + + assert result == mock_unmarshal.return_value + mock_unmarshal.assert_called_once_with(request, response) + + +class TestValidateAPICallRequest: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + validate_apicall_request(request, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + validate_apicall_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + validate_apicall_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_apicall_request(request, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + + with pytest.raises(TypeError): + validate_apicall_request(request, spec=spec_v31, cls=Exception) + + @mock.patch( + "openapi_core.validation.request.validators.APICallRequestValidator." + "validate", + ) + def test_request(self, mock_validate, spec_v31): + request = mock.Mock(spec=Request) + + result = validate_apicall_request(request, spec=spec_v31) + + assert result is None + mock_validate.assert_called_once_with(request) + + +class TestValidateWebhookRequest: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + validate_webhook_request(request, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + validate_webhook_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + validate_webhook_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=WebhookRequest) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_webhook_request(request, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(TypeError): + validate_webhook_request(request, spec=spec_v31, cls=Exception) + + def test_spec_oas30_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + validate_webhook_request(request, spec=spec_v30) + + @mock.patch( + "openapi_core.validation.request.validators.WebhookRequestValidator." + "validate", + ) + def test_request(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + + result = validate_webhook_request(request, spec=spec_v31) + + assert result is None + mock_validate.assert_called_once_with(request) + + +class TestValidateRequest: + def test_spec_invalid(self, spec_invalid): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + validate_request(request, spec=spec_invalid) + + def test_spec_not_detected(self, spec_v20): + request = mock.Mock(spec=Request) + + with pytest.raises(SpecError): + validate_request(request, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + + with pytest.raises(TypeError): + validate_request(request, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_request(request, spec=spec) + + @mock.patch( + "openapi_core.validation.request.validators.APICallRequestValidator." + "validate", + ) + def test_request(self, mock_validate, spec_v31): + request = mock.Mock(spec=Request) + mock_validate.return_value = None + + validate_request(request, spec=spec_v31) + + mock_validate.assert_called_once_with(request) + + def test_cls_apicall(self, spec_v31): + request = mock.Mock(spec=Request) + TestAPICallReq = type( + "TestAPICallReq", + (MockReqValidator, APICallRequestValidator), + {}, + ) + + result = validate_request(request, spec=spec_v31, cls=TestAPICallReq) + + assert result is None + assert TestAPICallReq.validate_calls == [ + (request,), + ] + + def test_cls_apicall_with_spec_validator_cls(self, spec_v31): + request = mock.Mock(spec=Request) + TestAPICallReq = type( + "TestAPICallReq", + (MockReqValidator, APICallRequestValidator), + { + "spec_validator_cls": OpenAPIV31SpecValidator, + }, + ) + + result = validate_request(request, spec=spec_v31, cls=TestAPICallReq) + + assert result is None + assert TestAPICallReq.validate_calls == [ + (request,), + ] + + def test_cls_webhook(self, spec_v31): + request = mock.Mock(spec=Request) + TestWebhookReq = type( + "TestWebhookReq", + (MockReqValidator, WebhookRequestValidator), + {}, + ) + + result = validate_request(request, spec=spec_v31, cls=TestWebhookReq) + + assert result is None + assert TestWebhookReq.validate_calls == [ + (request,), + ] + + def test_cls_webhook_with_spec_validator_cls(self, spec_v31): + request = mock.Mock(spec=Request) + TestWebhookReq = type( + "TestWebhookReq", + (MockReqValidator, WebhookRequestValidator), + { + "spec_validator_cls": OpenAPIV31SpecValidator, + }, + ) + + result = validate_request(request, spec=spec_v31, cls=TestWebhookReq) + + assert result is None + assert TestWebhookReq.validate_calls == [ + (request,), + ] + + def test_webhook_cls(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + TestWebhookReq = type( + "TestWebhookReq", + (MockReqValidator, WebhookRequestValidator), + {}, + ) + + result = validate_request(request, spec=spec_v31, cls=TestWebhookReq) + + assert result is None + assert TestWebhookReq.validate_calls == [ + (request,), + ] + + def test_cls_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + + with pytest.raises(TypeError): + validate_request(request, spec=spec_v31, cls=Exception) + + @mock.patch( + "openapi_core.validation.request.validators.V31WebhookRequestValidator." + "validate", + ) + def test_webhook_request(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + mock_validate.return_value = None + + validate_request(request, spec=spec_v31) + + mock_validate.assert_called_once_with(request) + + def test_webhook_request_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(SpecError): + validate_request(request, spec=spec_v30) + + @mock.patch( + "openapi_core.validation.request.validators.V31WebhookRequestValidator." + "validate", + ) + def test_webhook_request_error(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + mock_validate.side_effect = ValueError + + with pytest.raises(ValueError): + validate_request(request, spec=spec_v31) + + mock_validate.assert_called_once_with(request) + + def test_webhook_cls_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + + with pytest.raises(TypeError): + validate_request(request, spec=spec_v31, cls=Exception) + + +class TestValidateAPICallResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_apicall_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_apicall_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_apicall_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.sentinel.response + + with pytest.raises(TypeError): + validate_apicall_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_apicall_response(request, response, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_apicall_response( + request, response, spec=spec_v31, cls=Exception + ) + + @mock.patch( + "openapi_core.validation.response.validators.APICallResponseValidator." + "validate", + ) + def test_request_response(self, mock_validate, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + result = validate_apicall_response(request, response, spec=spec_v31) + + assert result is None + mock_validate.assert_called_once_with(request, response) + + +class TestValidateWebhookResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_webhook_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_webhook_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_webhook_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.sentinel.response + + with pytest.raises(TypeError): + validate_webhook_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_webhook_response(request, response, spec=spec) + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_webhook_response( + request, response, spec=spec_v31, cls=Exception + ) + + def test_spec_oas30_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_webhook_response(request, response, spec=spec_v30) + + @mock.patch( + "openapi_core.validation.response.validators.WebhookResponseValidator." + "validate", + ) + def test_request_response(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + result = validate_webhook_response(request, response, spec=spec_v31) + + assert result is None + mock_validate.assert_called_once_with(request, response) + + +class TestValidateResponse: + def test_spec_not_detected(self, spec_invalid): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_response(request, response, spec=spec_invalid) + + def test_spec_not_supported(self, spec_v20): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_response(request, response, spec=spec_v20) + + def test_request_type_invalid(self, spec_v31): + request = mock.sentinel.request + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec_v31) + + def test_response_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.sentinel.response + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec_v31) + + def test_spec_type_invalid(self): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + spec = mock.sentinel.spec + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec) + + @mock.patch( + "openapi_core.validation.response.validators.APICallResponseValidator." + "validate", + ) + def test_request_response(self, mock_validate, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + mock_validate.return_value = None + + validate_response(request, response, spec=spec_v31) + + mock_validate.assert_called_once_with(request, response) + + def test_cls_apicall(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + TestAPICallResp = type( + "TestAPICallResp", + (MockRespValidator, APICallResponseValidator), + {}, + ) + + result = validate_response( + request, response, spec=spec_v31, cls=TestAPICallResp + ) + + assert result is None + assert TestAPICallResp.validate_calls == [ + (request, response), + ] + + def test_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=Request) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec_v31, cls=Exception) + + def test_webhook_response_validator_not_found(self, spec_v30): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(SpecError): + validate_response(request, response, spec=spec_v30) + + @mock.patch( + "openapi_core.validation.response.validators.V31WebhookResponseValidator." + "validate", + ) + def test_webhook_request(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + mock_validate.return_value = None + + validate_response(request, response, spec=spec_v31) + + mock_validate.assert_called_once_with(request, response) + + @mock.patch( + "openapi_core.validation.response.validators.V31WebhookResponseValidator." + "validate", + ) + def test_webhook_request_error(self, mock_validate, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + mock_validate.side_effect = ValueError + + with pytest.raises(ValueError): + validate_response(request, response, spec=spec_v31) + + mock_validate.assert_called_once_with(request, response) + + def test_webhook_cls(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + TestWebhookResp = type( + "TestWebhookResp", + (MockRespValidator, WebhookResponseValidator), + {}, + ) + + result = validate_response( + request, response, spec=spec_v31, cls=TestWebhookResp + ) + + assert result is None + assert TestWebhookResp.validate_calls == [ + (request, response), + ] + + def test_webhook_cls_type_invalid(self, spec_v31): + request = mock.Mock(spec=WebhookRequest) + response = mock.Mock(spec=Response) + + with pytest.raises(TypeError): + validate_response(request, response, spec=spec_v31, cls=Exception) diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py new file mode 100644 index 00000000..a262d0c4 --- /dev/null +++ b/tests/unit/test_util.py @@ -0,0 +1,22 @@ +import pytest + +from openapi_core.util import forcebool + + +class TestForcebool: + @pytest.mark.parametrize("val", ["y", "yes", "t", "true", "on", "1", True]) + def test_true(self, val): + result = forcebool(val) + assert result is True + + @pytest.mark.parametrize( + "val", ["n", "no", "f", "false", "off", "0", False] + ) + def test_false(self, val): + result = forcebool(val) + assert result is False + + @pytest.mark.parametrize("val", ["random", "idontknow", ""]) + def test_value_error(self, val): + with pytest.raises(ValueError): + forcebool(val) diff --git a/tests/unit/unmarshalling/test_path_item_params_validator.py b/tests/unit/unmarshalling/test_path_item_params_validator.py new file mode 100644 index 00000000..cf41e6d9 --- /dev/null +++ b/tests/unit/unmarshalling/test_path_item_params_validator.py @@ -0,0 +1,171 @@ +from dataclasses import is_dataclass + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core import V30RequestUnmarshaller +from openapi_core import unmarshal_request +from openapi_core import validate_request +from openapi_core.casting.schemas.exceptions import CastError +from openapi_core.datatypes import Parameters +from openapi_core.testing import MockRequest +from openapi_core.validation.request.exceptions import MissingRequiredParameter +from openapi_core.validation.request.exceptions import ParameterValidationError + + +class TestPathItemParamsValidator: + @pytest.fixture(scope="session") + def spec_dict(self): + return { + "openapi": "3.0.0", + "info": { + "title": "Test path item parameter validation", + "version": "0.1", + }, + "paths": { + "/resource": { + "parameters": [ + { + "name": "resId", + "in": "query", + "required": True, + "schema": { + "type": "integer", + }, + }, + ], + "get": { + "responses": { + "default": {"description": "Return the resource."} + } + }, + } + }, + } + + @pytest.fixture(scope="session") + def spec(self, spec_dict): + return SchemaPath.from_dict(spec_dict) + + @pytest.fixture(scope="session") + def request_unmarshaller(self, spec): + return V30RequestUnmarshaller(spec) + + def test_request_missing_param(self, request_unmarshaller): + request = MockRequest("http://example.com", "get", "/resource") + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 1 + assert type(result.errors[0]) == MissingRequiredParameter + assert result.body is None + assert result.parameters == Parameters() + + def test_request_invalid_param(self, request_unmarshaller): + request = MockRequest( + "http://example.com", + "get", + "/resource", + args={"resId": "invalid"}, + ) + + result = request_unmarshaller.unmarshal(request) + + assert result.errors == [ + ParameterValidationError(name="resId", location="query") + ] + assert type(result.errors[0].__cause__) is CastError + assert result.body is None + assert result.parameters == Parameters() + + def test_request_valid_param(self, request_unmarshaller): + request = MockRequest( + "http://example.com", + "get", + "/resource", + args={"resId": "10"}, + ) + + result = request_unmarshaller.unmarshal(request) + + assert len(result.errors) == 0 + assert result.body is None + assert result.parameters == Parameters(query={"resId": 10}) + + def test_request_override_param(self, spec, spec_dict): + # override path parameter on operation + spec_dict["paths"]["/resource"]["get"]["parameters"] = [ + { + # full valid parameter object required + "name": "resId", + "in": "query", + "required": False, + "schema": { + "type": "integer", + }, + } + ] + request = MockRequest("http://example.com", "get", "/resource") + result = unmarshal_request( + request, spec, base_url="http://example.com" + ) + + assert len(result.errors) == 0 + assert result.body is None + assert result.parameters == Parameters() + + def test_request_override_param_uniqueness(self, spec, spec_dict): + # add parameter on operation with same name as on path but + # different location + spec_dict["paths"]["/resource"]["get"]["parameters"] = [ + { + # full valid parameter object required + "name": "resId", + "in": "header", + "required": False, + "schema": { + "type": "integer", + }, + } + ] + request = MockRequest("http://example.com", "get", "/resource") + with pytest.raises(MissingRequiredParameter): + validate_request(request, spec, base_url="http://example.com") + + def test_request_object_deep_object_params(self, spec, spec_dict): + # override path parameter on operation + spec_dict["paths"]["/resource"]["parameters"] = [ + { + # full valid parameter object required + "name": "paramObj", + "in": "query", + "required": True, + "schema": { + "x-model": "paramObj", + "type": "object", + "properties": { + "count": {"type": "integer"}, + "name": {"type": "string"}, + }, + }, + "explode": True, + "style": "deepObject", + } + ] + + request = MockRequest( + "http://example.com", + "get", + "/resource", + args={"paramObj[count]": 2, "paramObj[name]": "John"}, + ) + result = unmarshal_request( + request, spec, base_url="http://example.com" + ) + + assert len(result.errors) == 0 + assert result.body is None + assert len(result.parameters.query) == 1 + assert is_dataclass(result.parameters.query["paramObj"]) + assert result.parameters.query["paramObj"].count == 2 + assert result.parameters.query["paramObj"].name == "John" diff --git a/tests/unit/unmarshalling/test_request_unmarshallers.py b/tests/unit/unmarshalling/test_request_unmarshallers.py new file mode 100644 index 00000000..a407d567 --- /dev/null +++ b/tests/unit/unmarshalling/test_request_unmarshallers.py @@ -0,0 +1,136 @@ +import enum + +import pytest +from jsonschema_path import SchemaPath + +from openapi_core import V30RequestUnmarshaller +from openapi_core import V31RequestUnmarshaller +from openapi_core.datatypes import Parameters +from openapi_core.testing import MockRequest + + +class Colors(enum.Enum): + + YELLOW = "yellow" + BLUE = "blue" + RED = "red" + + @classmethod + def of(cls, v: str): + for it in cls: + if it.value == v: + return it + raise ValueError(f"Invalid value: {v}") + + +class TestRequestUnmarshaller: + + @pytest.fixture(scope="session") + def spec_dict(self): + return { + "openapi": "3.1.0", + "info": { + "title": "Test request body unmarshaller", + "version": "0.1", + }, + "paths": { + "/resources": { + "post": { + "description": "POST resources test request", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/createResource" + } + } + }, + }, + "responses": { + "201": {"description": "Resource was created."} + }, + }, + "get": { + "description": "POST resources test request", + "parameters": [ + { + "name": "color", + "in": "query", + "required": False, + "schema": { + "$ref": "#/components/schemas/colors" + }, + }, + ], + "responses": { + "default": { + "description": "Returned resources matching request." + } + }, + }, + } + }, + "components": { + "schemas": { + "colors": { + "type": "string", + "enum": ["yellow", "blue", "red"], + "format": "enum_Colors", + }, + "createResource": { + "type": "object", + "properties": { + "resId": {"type": "integer"}, + "color": {"$ref": "#/components/schemas/colors"}, + }, + "required": ["resId", "color"], + }, + } + }, + } + + @pytest.fixture(scope="session") + def spec(self, spec_dict): + return SchemaPath.from_dict(spec_dict) + + @pytest.mark.parametrize( + "req_unmarshaller_cls", + [V30RequestUnmarshaller, V31RequestUnmarshaller], + ) + def test_request_body_extra_unmarshaller(self, spec, req_unmarshaller_cls): + ru = req_unmarshaller_cls( + spec=spec, extra_format_unmarshallers={"enum_Colors": Colors.of} + ) + request = MockRequest( + host_url="http://example.com", + method="post", + path="/resources", + data=b'{"resId": 23498572, "color": "blue"}', + ) + result = ru.unmarshal(request) + + assert not result.errors + assert result.body == {"resId": 23498572, "color": Colors.BLUE} + assert result.parameters == Parameters() + + @pytest.mark.parametrize( + "req_unmarshaller_cls", + [V30RequestUnmarshaller, V31RequestUnmarshaller], + ) + def test_request_param_extra_unmarshaller( + self, spec, req_unmarshaller_cls + ): + ru = req_unmarshaller_cls( + spec=spec, extra_format_unmarshallers={"enum_Colors": Colors.of} + ) + request = MockRequest( + host_url="http://example.com", + method="get", + path="/resources", + args={"color": "blue"}, + ) + result = ru.unmarshal(request) + + assert not result.errors + assert result.parameters == Parameters(query=dict(color=Colors.BLUE)) diff --git a/tests/unit/unmarshalling/test_schema_unmarshallers.py b/tests/unit/unmarshalling/test_schema_unmarshallers.py new file mode 100644 index 00000000..5a8fe12e --- /dev/null +++ b/tests/unit/unmarshalling/test_schema_unmarshallers.py @@ -0,0 +1,254 @@ +from functools import partial + +import pytest +from jsonschema_path import SchemaPath +from openapi_schema_validator import OAS30WriteValidator + +from openapi_core.unmarshalling.schemas import oas30_types_unmarshaller +from openapi_core.unmarshalling.schemas.exceptions import ( + FormatterNotFoundError, +) +from openapi_core.unmarshalling.schemas.factories import ( + SchemaUnmarshallersFactory, +) +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, +) +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue +from openapi_core.validation.schemas.factories import SchemaValidatorsFactory + + +@pytest.fixture +def schema_unmarshaller_factory(): + def create_unmarshaller( + validators_factory, + schema, + format_validators=None, + extra_format_validators=None, + extra_format_unmarshallers=None, + ): + return SchemaUnmarshallersFactory( + validators_factory, + oas30_types_unmarshaller, + ).create( + schema, + format_validators=format_validators, + extra_format_validators=extra_format_validators, + extra_format_unmarshallers=extra_format_unmarshallers, + ) + + return create_unmarshaller + + +@pytest.fixture +def unmarshaller_factory(schema_unmarshaller_factory): + return partial( + schema_unmarshaller_factory, + oas30_write_schema_validators_factory, + ) + + +class TestOAS30SchemaUnmarshallerFactoryCreate: + def test_string_format_unknown(self, unmarshaller_factory): + unknown_format = "unknown" + schema = { + "type": "string", + "format": unknown_format, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(FormatterNotFoundError): + unmarshaller_factory(spec) + + def test_string_format_invalid_value(self, unmarshaller_factory): + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises( + FormatterNotFoundError, + match="Formatter not found for custom format", + ): + unmarshaller_factory(spec) + + +class TestOAS30SchemaUnmarshallerUnmarshal: + def test_schema_extra_format_unmarshaller_format_invalid( + self, schema_unmarshaller_factory, unmarshaller_factory + ): + def custom_format_unmarshaller(value): + raise ValueError + + custom_format = "custom" + schema = { + "type": "string", + "format": "custom", + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + extra_format_unmarshallers = { + custom_format: custom_format_unmarshaller, + } + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + extra_format_unmarshallers=extra_format_unmarshallers, + ) + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_schema_extra_format_unmarshaller_format_custom( + self, schema_unmarshaller_factory + ): + formatted = "x-custom" + + def custom_format_unmarshaller(value): + return formatted + + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + extra_format_unmarshallers = { + custom_format: custom_format_unmarshaller, + } + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + extra_format_unmarshallers=extra_format_unmarshallers, + ) + + result = unmarshaller.unmarshal(value) + + assert result == formatted + + def test_schema_extra_format_validator_format_invalid( + self, schema_unmarshaller_factory, unmarshaller_factory + ): + def custom_format_validator(value): + return False + + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + extra_format_validators = { + custom_format: custom_format_validator, + } + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + extra_format_validators=extra_format_validators, + ) + + with pytest.raises(InvalidSchemaValue): + unmarshaller.unmarshal(value) + + def test_schema_extra_format_validator_format_custom( + self, schema_unmarshaller_factory + ): + def custom_format_validator(value): + return True + + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + extra_format_validators = { + custom_format: custom_format_validator, + } + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + extra_format_validators=extra_format_validators, + ) + + result = unmarshaller.unmarshal(value) + + assert result == value + + @pytest.mark.xfail( + reason=( + "Not registered format raises FormatterNotFoundError" + "See https://github.com/python-openapi/openapi-core/issues/515" + ), + strict=True, + ) + def test_schema_format_validator_format_invalid( + self, schema_unmarshaller_factory, unmarshaller_factory + ): + custom_format = "date" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + format_validators = {} + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + format_validators=format_validators, + ) + + result = unmarshaller.unmarshal(value) + + assert result == value + + def test_schema_format_validator_format_custom( + self, schema_unmarshaller_factory, unmarshaller_factory + ): + def custom_format_validator(value): + return True + + custom_format = "date" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + schema_validators_factory = SchemaValidatorsFactory( + OAS30WriteValidator + ) + format_validators = { + custom_format: custom_format_validator, + } + unmarshaller = schema_unmarshaller_factory( + schema_validators_factory, + spec, + format_validators=format_validators, + ) + + result = unmarshaller.unmarshal(value) + + assert result == value diff --git a/tests/unit/unmarshalling/test_unmarshal.py b/tests/unit/unmarshalling/test_unmarshal.py deleted file mode 100644 index 906689e5..00000000 --- a/tests/unit/unmarshalling/test_unmarshal.py +++ /dev/null @@ -1,593 +0,0 @@ -import datetime -import uuid - -from isodate.tzinfo import UTC, FixedOffset -import pytest - -from openapi_core.schema.media_types.models import MediaType -from openapi_core.schema.parameters.models import Parameter -from openapi_core.schema.schemas.enums import SchemaType -from openapi_core.schema.schemas.models import Schema -from openapi_core.schema.schemas.types import NoValue -from openapi_core.unmarshalling.schemas.enums import UnmarshalContext -from openapi_core.unmarshalling.schemas.exceptions import ( - InvalidSchemaFormatValue, InvalidSchemaValue, UnmarshalError, - FormatterNotFoundError, -) -from openapi_core.unmarshalling.schemas.factories import ( - SchemaUnmarshallersFactory, -) -from openapi_core.unmarshalling.schemas.formatters import Formatter -from openapi_core.unmarshalling.schemas.util import build_format_checker - - -@pytest.fixture -def unmarshaller_factory(): - def create_unmarshaller(schema, custom_formatters=None, context=None): - custom_formatters = custom_formatters or {} - format_checker = build_format_checker(**custom_formatters) - return SchemaUnmarshallersFactory( - format_checker=format_checker, - custom_formatters=custom_formatters, context=context).create( - schema) - return create_unmarshaller - - -class TestParameterUnmarshal(object): - - def test_no_schema(self, unmarshaller_factory): - param = Parameter('param', 'query') - value = 'test' - - with pytest.raises(TypeError): - unmarshaller_factory(param.schema).unmarshal(value) - - def test_schema_type_invalid(self, unmarshaller_factory): - schema = Schema('integer', _source={'type': 'integer'}) - param = Parameter('param', 'query', schema=schema) - value = 'test' - - with pytest.raises(InvalidSchemaFormatValue): - unmarshaller_factory(param.schema).unmarshal(value) - - def test_schema_custom_format_invalid(self, unmarshaller_factory): - - class CustomFormatter(Formatter): - def unmarshal(self, value): - raise ValueError - formatter = CustomFormatter() - custom_format = 'custom' - custom_formatters = { - custom_format: formatter, - } - schema = Schema( - 'string', - schema_format=custom_format, - _source={'type': 'string', 'format': 'custom'}, - ) - param = Parameter('param', 'query', schema=schema) - value = 'test' - - with pytest.raises(InvalidSchemaFormatValue): - unmarshaller_factory( - param.schema, - custom_formatters=custom_formatters, - ).unmarshal(value) - - -class TestMediaTypeUnmarshal(object): - - def test_no_schema(self, unmarshaller_factory): - media_type = MediaType('application/json') - value = 'test' - - with pytest.raises(TypeError): - unmarshaller_factory(media_type.schema).unmarshal(value) - - def test_schema_type_invalid(self, unmarshaller_factory): - schema = Schema('integer', _source={'type': 'integer'}) - media_type = MediaType('application/json', schema=schema) - value = 'test' - - with pytest.raises(InvalidSchemaFormatValue): - unmarshaller_factory(media_type.schema).unmarshal(value) - - def test_schema_custom_format_invalid(self, unmarshaller_factory): - - class CustomFormatter(Formatter): - def unmarshal(self, value): - raise ValueError - formatter = CustomFormatter() - custom_format = 'custom' - custom_formatters = { - custom_format: formatter, - } - schema = Schema( - 'string', - schema_format=custom_format, - _source={'type': 'string', 'format': 'custom'}, - ) - media_type = MediaType('application/json', schema=schema) - value = 'test' - - with pytest.raises(InvalidSchemaFormatValue): - unmarshaller_factory( - media_type.schema, - custom_formatters=custom_formatters, - ).unmarshal(value) - - -class TestSchemaUnmarshallerCall(object): - - def test_deprecated(self, unmarshaller_factory): - schema = Schema('string', deprecated=True) - value = 'test' - - with pytest.warns(DeprecationWarning): - result = unmarshaller_factory(schema)(value) - - assert result == value - - @pytest.mark.parametrize('schema_type', [ - 'boolean', 'array', 'integer', 'number', - ]) - def test_non_string_empty_value(self, schema_type, unmarshaller_factory): - schema = Schema(schema_type) - value = '' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_string_valid(self, unmarshaller_factory): - schema = Schema('string') - value = 'test' - - result = unmarshaller_factory(schema)(value) - - assert result == value - - def test_string_format_uuid_valid(self, unmarshaller_factory): - schema = Schema(SchemaType.STRING, schema_format='uuid') - value = str(uuid.uuid4()) - - result = unmarshaller_factory(schema)(value) - - assert result == uuid.UUID(value) - - def test_string_format_uuid_uuid_quirks_invalid( - self, unmarshaller_factory): - schema = Schema(SchemaType.STRING, schema_format='uuid') - value = uuid.uuid4() - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_string_format_password(self, unmarshaller_factory): - schema = Schema(SchemaType.STRING, schema_format='password') - value = 'password' - - result = unmarshaller_factory(schema)(value) - - assert result == 'password' - - def test_string_float_invalid(self, unmarshaller_factory): - schema = Schema('string') - value = 1.23 - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_string_default(self, unmarshaller_factory): - default_value = 'default' - schema = Schema('string', default=default_value) - value = NoValue - - result = unmarshaller_factory(schema)(value) - - assert result == default_value - - @pytest.mark.parametrize('default_value', ['default', None]) - def test_string_default_nullable( - self, default_value, unmarshaller_factory): - schema = Schema('string', default=default_value, nullable=True) - value = NoValue - - result = unmarshaller_factory(schema)(value) - - assert result == default_value - - def test_string_format_date(self, unmarshaller_factory): - schema = Schema('string', schema_format='date') - value = '2018-01-02' - - result = unmarshaller_factory(schema)(value) - - assert result == datetime.date(2018, 1, 2) - - def test_string_format_datetime_invalid(self, unmarshaller_factory): - schema = Schema('string', schema_format='date-time') - value = '2018-01-02T00:00:00' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_string_format_datetime_utc(self, unmarshaller_factory): - schema = Schema('string', schema_format='date-time') - value = '2018-01-02T00:00:00Z' - - result = unmarshaller_factory(schema)(value) - - tzinfo = UTC - assert result == datetime.datetime(2018, 1, 2, 0, 0, tzinfo=tzinfo) - - def test_string_format_datetime_tz(self, unmarshaller_factory): - schema = Schema('string', schema_format='date-time') - value = '2020-04-01T12:00:00+02:00' - - result = unmarshaller_factory(schema)(value) - - tzinfo = FixedOffset(2) - assert result == datetime.datetime(2020, 4, 1, 12, 0, 0, tzinfo=tzinfo) - - def test_string_format_custom(self, unmarshaller_factory): - formatted = 'x-custom' - - class CustomFormatter(Formatter): - def unmarshal(self, value): - return formatted - custom_format = 'custom' - schema = Schema('string', schema_format=custom_format) - value = 'x' - formatter = CustomFormatter() - custom_formatters = { - custom_format: formatter, - } - - result = unmarshaller_factory( - schema, custom_formatters=custom_formatters)(value) - - assert result == formatted - - def test_string_format_custom_value_error(self, unmarshaller_factory): - - class CustomFormatter(Formatter): - def unmarshal(self, value): - raise ValueError - custom_format = 'custom' - schema = Schema('string', schema_format=custom_format) - value = 'x' - formatter = CustomFormatter() - custom_formatters = { - custom_format: formatter, - } - - with pytest.raises(InvalidSchemaFormatValue): - unmarshaller_factory(schema, custom_formatters=custom_formatters)( - value) - - def test_string_format_unknown(self, unmarshaller_factory): - unknown_format = 'unknown' - schema = Schema('string', schema_format=unknown_format) - value = 'x' - - with pytest.raises(FormatterNotFoundError): - unmarshaller_factory(schema)(value) - - def test_string_format_invalid_value(self, unmarshaller_factory): - custom_format = 'custom' - schema = Schema('string', schema_format=custom_format) - value = 'x' - - with pytest.raises( - FormatterNotFoundError, - message=( - 'Formatter not found for custom format' - ), - ): - unmarshaller_factory(schema)(value) - - def test_integer_valid(self, unmarshaller_factory): - schema = Schema('integer') - value = 123 - - result = unmarshaller_factory(schema)(value) - - assert result == int(value) - - def test_integer_string_invalid(self, unmarshaller_factory): - schema = Schema('integer') - value = '123' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_integer_enum_invalid(self, unmarshaller_factory): - schema = Schema('integer', enum=[1, 2, 3]) - value = '123' - - with pytest.raises(UnmarshalError): - unmarshaller_factory(schema)(value) - - def test_integer_enum(self, unmarshaller_factory): - schema = Schema('integer', enum=[1, 2, 3]) - value = 2 - - result = unmarshaller_factory(schema)(value) - - assert result == int(value) - - def test_integer_enum_string_invalid(self, unmarshaller_factory): - schema = Schema('integer', enum=[1, 2, 3]) - value = '2' - - with pytest.raises(UnmarshalError): - unmarshaller_factory(schema)(value) - - def test_integer_default(self, unmarshaller_factory): - default_value = 123 - schema = Schema('integer', default=default_value) - value = NoValue - - result = unmarshaller_factory(schema)(value) - - assert result == default_value - - def test_integer_default_nullable(self, unmarshaller_factory): - default_value = 123 - schema = Schema('integer', default=default_value, nullable=True) - value = None - - result = unmarshaller_factory(schema)(value) - - assert result is None - - def test_integer_invalid(self, unmarshaller_factory): - schema = Schema('integer') - value = 'abc' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_array_valid(self, unmarshaller_factory): - schema = Schema('array', items=Schema('integer')) - value = [1, 2, 3] - - result = unmarshaller_factory(schema)(value) - - assert result == value - - def test_array_null(self, unmarshaller_factory): - schema = Schema( - 'array', - items=Schema('integer'), - ) - value = None - - with pytest.raises(TypeError): - unmarshaller_factory(schema)(value) - - def test_array_nullable(self, unmarshaller_factory): - schema = Schema( - 'array', - items=Schema('integer'), - nullable=True, - ) - value = None - result = unmarshaller_factory(schema)(value) - - assert result is None - - def test_array_of_string_string_invalid(self, unmarshaller_factory): - schema = Schema('array', items=Schema('string')) - value = '123' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_array_of_integer_string_invalid(self, unmarshaller_factory): - schema = Schema('array', items=Schema('integer')) - value = '123' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_boolean_valid(self, unmarshaller_factory): - schema = Schema('boolean') - value = True - - result = unmarshaller_factory(schema)(value) - - assert result == value - - def test_boolean_string_invalid(self, unmarshaller_factory): - schema = Schema('boolean') - value = 'True' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_number_valid(self, unmarshaller_factory): - schema = Schema('number') - value = 1.23 - - result = unmarshaller_factory(schema)(value) - - assert result == value - - def test_number_string_invalid(self, unmarshaller_factory): - schema = Schema('number') - value = '1.23' - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_number_int(self, unmarshaller_factory): - schema = Schema('number') - value = 1 - result = unmarshaller_factory(schema)(value) - - assert result == 1 - assert type(result) == int - - def test_number_float(self, unmarshaller_factory): - schema = Schema('number') - value = 1.2 - result = unmarshaller_factory(schema)(value) - - assert result == 1.2 - assert type(result) == float - - def test_number_format_float(self, unmarshaller_factory): - schema = Schema('number', schema_format='float') - value = 1.2 - result = unmarshaller_factory(schema)(value) - - assert result == 1.2 - - def test_number_format_double(self, unmarshaller_factory): - schema = Schema('number', schema_format='double') - value = 1.2 - result = unmarshaller_factory(schema)(value) - - assert result == 1.2 - - def test_object_nullable(self, unmarshaller_factory): - schema = Schema( - 'object', - properties={ - 'foo': Schema('object', nullable=True), - }, - ) - value = {'foo': None} - result = unmarshaller_factory(schema)(value) - - assert result == {'foo': None} - - def test_schema_any_one_of(self, unmarshaller_factory): - schema = Schema(one_of=[ - Schema('string'), - Schema('array', items=Schema('string')), - ]) - assert unmarshaller_factory(schema)(['hello']) == ['hello'] - - def test_schema_any_all_of(self, unmarshaller_factory): - schema = Schema(all_of=[ - Schema('array', items=Schema('string')), - ]) - assert unmarshaller_factory(schema)(['hello']) == ['hello'] - - @pytest.mark.parametrize('value', [ - { - 'somestr': {}, - 'someint': 123, - }, - { - 'somestr': [ - 'content1', 'content2' - ], - 'someint': 123, - }, - { - 'somestr': 123, - 'someint': 123, - }, - { - 'somestr': 'content', - 'someint': 123, - 'not_in_scheme_prop': 123, - }, - ]) - def test_schema_any_all_of_invalid_properties( - self, value, unmarshaller_factory): - schema = Schema( - all_of=[ - Schema( - 'object', - required=['somestr'], - properties={ - 'somestr': Schema('string'), - }, - ), - Schema( - 'object', - required=['someint'], - properties={ - 'someint': Schema('integer'), - }, - ), - ], - additional_properties=False, - ) - - with pytest.raises(InvalidSchemaValue): - unmarshaller_factory(schema)(value) - - def test_schema_any_all_of_any(self, unmarshaller_factory): - schema = Schema(all_of=[ - Schema(), - Schema('string', schema_format='date'), - ]) - value = '2018-01-02' - - result = unmarshaller_factory(schema)(value) - - assert result == datetime.date(2018, 1, 2) - - def test_schema_any(self, unmarshaller_factory): - schema = Schema() - assert unmarshaller_factory(schema)('string') == 'string' - - @pytest.mark.parametrize('value', [ - {'additional': 1}, - {'foo': 'bar', 'bar': 'foo'}, - {'additional': {'bar': 1}}, - ]) - @pytest.mark.parametrize('additional_properties', [True, Schema()]) - def test_schema_free_form_object( - self, value, additional_properties, unmarshaller_factory): - schema = Schema('object', additional_properties=additional_properties) - - result = unmarshaller_factory(schema)(value) - assert result == value - - def test_read_only_properties(self, unmarshaller_factory): - id_property = Schema('integer', read_only=True) - - def properties(): - yield ('id', id_property) - - obj_schema = Schema('object', properties=properties(), required=['id']) - - # readOnly properties may be admitted in a Response context - result = unmarshaller_factory( - obj_schema, context=UnmarshalContext.RESPONSE)({"id": 10}) - assert result == { - 'id': 10, - } - - # readOnly properties are not admitted on a Request context - result = unmarshaller_factory( - obj_schema, context=UnmarshalContext.REQUEST)({"id": 10}) - - assert result == {} - - def test_write_only_properties(self, unmarshaller_factory): - id_property = Schema('integer', write_only=True) - - def properties(): - yield ('id', id_property) - - obj_schema = Schema('object', properties=properties(), required=['id']) - - # readOnly properties may be admitted in a Response context - result = unmarshaller_factory( - obj_schema, context=UnmarshalContext.REQUEST)({"id": 10}) - assert result == { - 'id': 10, - } - - # readOnly properties are not admitted on a Request context - result = unmarshaller_factory( - obj_schema, context=UnmarshalContext.RESPONSE)({"id": 10}) - - assert result == {} diff --git a/tests/unit/unmarshalling/test_validate.py b/tests/unit/unmarshalling/test_validate.py deleted file mode 100644 index 6c91ce37..00000000 --- a/tests/unit/unmarshalling/test_validate.py +++ /dev/null @@ -1,764 +0,0 @@ -import datetime - -import mock -import pytest - -from openapi_core.extensions.models.models import Model -from openapi_core.schema.schemas.exceptions import OpenAPISchemaError -from openapi_core.schema.schemas.models import Schema -from openapi_core.unmarshalling.schemas.factories import ( - SchemaUnmarshallersFactory, -) -from openapi_core.unmarshalling.schemas.exceptions import ( - FormatterNotFoundError, InvalidSchemaValue, -) -from openapi_core.unmarshalling.schemas.util import build_format_checker - -from six import b, u - - -class TestSchemaValidate(object): - - @pytest.fixture - def validator_factory(self): - def create_validator(schema): - format_checker = build_format_checker() - return SchemaUnmarshallersFactory( - format_checker=format_checker).create(schema) - return create_validator - - @pytest.mark.parametrize('schema_type', [ - 'boolean', 'array', 'integer', 'number', 'string', - ]) - def test_null(self, schema_type, validator_factory): - schema = Schema(schema_type) - value = None - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('schema_type', [ - 'boolean', 'array', 'integer', 'number', 'string', - ]) - def test_nullable(self, schema_type, validator_factory): - schema = Schema(schema_type, nullable=True) - value = None - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.xfail( - reason="validation does not care about custom formats atm") - def test_string_format_custom_missing(self, validator_factory): - custom_format = 'custom' - schema = Schema('string', schema_format=custom_format) - value = 'x' - - with pytest.raises(OpenAPISchemaError): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [False, True]) - def test_boolean(self, value, validator_factory): - schema = Schema('boolean') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [1, 3.14, u('true'), [True, False]]) - def test_boolean_invalid(self, value, validator_factory): - schema = Schema('boolean') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [(1, 2)]) - def test_array_no_schema(self, value, validator_factory): - schema = Schema('array') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [[1, 2]]) - def test_array(self, value, validator_factory): - schema = Schema('array', items=Schema('integer')) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [False, 1, 3.14, u('true'), (3, 4)]) - def test_array_invalid(self, value, validator_factory): - schema = Schema('array') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [1, 3]) - def test_integer(self, value, validator_factory): - schema = Schema('integer') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [False, 3.14, u('true'), [1, 2]]) - def test_integer_invalid(self, value, validator_factory): - schema = Schema('integer') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [0, 1, 2]) - def test_integer_minimum_invalid(self, value, validator_factory): - schema = Schema('integer', minimum=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [4, 5, 6]) - def test_integer_minimum(self, value, validator_factory): - schema = Schema('integer', minimum=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [4, 5, 6]) - def test_integer_maximum_invalid(self, value, validator_factory): - schema = Schema('integer', maximum=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [0, 1, 2]) - def test_integer_maximum(self, value, validator_factory): - schema = Schema('integer', maximum=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [1, 2, 4]) - def test_integer_multiple_of_invalid(self, value, validator_factory): - schema = Schema('integer', multiple_of=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [3, 6, 18]) - def test_integer_multiple_of(self, value, validator_factory): - schema = Schema('integer', multiple_of=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [1, 3.14]) - def test_number(self, value, validator_factory): - schema = Schema('number') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [False, 'true', [1, 3]]) - def test_number_invalid(self, value, validator_factory): - schema = Schema('number') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [0, 1, 2]) - def test_number_minimum_invalid(self, value, validator_factory): - schema = Schema('number', minimum=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [3, 4, 5]) - def test_number_minimum(self, value, validator_factory): - schema = Schema('number', minimum=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [1, 2, 3]) - def test_number_exclusive_minimum_invalid(self, value, validator_factory): - schema = Schema('number', minimum=3, exclusive_minimum=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [4, 5, 6]) - def test_number_exclusive_minimum(self, value, validator_factory): - schema = Schema('number', minimum=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [4, 5, 6]) - def test_number_maximum_invalid(self, value, validator_factory): - schema = Schema('number', maximum=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [1, 2, 3]) - def test_number_maximum(self, value, validator_factory): - schema = Schema('number', maximum=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [3, 4, 5]) - def test_number_exclusive_maximum_invalid(self, value, validator_factory): - schema = Schema('number', maximum=3, exclusive_maximum=True) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [0, 1, 2]) - def test_number_exclusive_maximum(self, value, validator_factory): - schema = Schema('number', maximum=3, exclusive_maximum=True) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [1, 2, 4]) - def test_number_multiple_of_invalid(self, value, validator_factory): - schema = Schema('number', multiple_of=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [3, 6, 18]) - def test_number_multiple_of(self, value, validator_factory): - schema = Schema('number', multiple_of=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [u('true'), b('test')]) - def test_string(self, value, validator_factory): - schema = Schema('string') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [False, 1, 3.14, [1, 3]]) - def test_string_invalid(self, value, validator_factory): - schema = Schema('string') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - b('true'), u('test'), False, 1, 3.14, [1, 3], - datetime.datetime(1989, 1, 2), - ]) - def test_string_format_date_invalid(self, value, validator_factory): - schema = Schema('string', schema_format='date') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - u('1989-01-02'), u('2018-01-02'), - ]) - def test_string_format_date(self, value, validator_factory): - schema = Schema('string', schema_format='date') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - u('12345678-1234-5678-1234-567812345678'), - ]) - def test_string_format_uuid(self, value, validator_factory): - schema = Schema('string', schema_format='uuid') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - b('true'), u('true'), False, 1, 3.14, [1, 3], - datetime.date(2018, 1, 2), datetime.datetime(2018, 1, 2, 23, 59, 59), - ]) - def test_string_format_uuid_invalid(self, value, validator_factory): - schema = Schema('string', schema_format='uuid') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - b('true'), u('true'), False, 1, 3.14, [1, 3], - u('1989-01-02'), - ]) - def test_string_format_datetime_invalid(self, value, validator_factory): - schema = Schema('string', schema_format='date-time') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - u('1989-01-02T00:00:00Z'), - u('2018-01-02T23:59:59Z'), - ]) - @mock.patch( - 'openapi_schema_validator._format.' - 'DATETIME_HAS_STRICT_RFC3339', True - ) - @mock.patch( - 'openapi_schema_validator._format.' - 'DATETIME_HAS_ISODATE', False - ) - def test_string_format_datetime_strict_rfc3339( - self, value, validator_factory): - schema = Schema('string', schema_format='date-time') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - u('1989-01-02T00:00:00Z'), - u('2018-01-02T23:59:59Z'), - ]) - @mock.patch( - 'openapi_schema_validator._format.' - 'DATETIME_HAS_STRICT_RFC3339', False - ) - @mock.patch( - 'openapi_schema_validator._format.' - 'DATETIME_HAS_ISODATE', True - ) - def test_string_format_datetime_isodate(self, value, validator_factory): - schema = Schema('string', schema_format='date-time') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - u('true'), False, 1, 3.14, [1, 3], u('1989-01-02'), - u('1989-01-02T00:00:00Z'), - ]) - def test_string_format_binary_invalid(self, value, validator_factory): - schema = Schema('string', schema_format='binary') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - b('stream'), b('text'), - ]) - def test_string_format_binary(self, value, validator_factory): - schema = Schema('string', schema_format='binary') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - b('dGVzdA=='), u('dGVzdA=='), - ]) - def test_string_format_byte(self, value, validator_factory): - schema = Schema('string', schema_format='byte') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - u('tsssst'), b('tsssst'), b('tesddddsdsdst'), - ]) - def test_string_format_byte_invalid(self, value, validator_factory): - schema = Schema('string', schema_format='byte') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - u('test'), b('stream'), datetime.date(1989, 1, 2), - datetime.datetime(1989, 1, 2, 0, 0, 0), - ]) - def test_string_format_unknown(self, value, validator_factory): - unknown_format = 'unknown' - schema = Schema('string', schema_format=unknown_format) - - with pytest.raises(FormatterNotFoundError): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [u(""), u("a"), u("ab")]) - def test_string_min_length_invalid(self, value, validator_factory): - schema = Schema('string', min_length=3) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [u("abc"), u("abcd")]) - def test_string_min_length(self, value, validator_factory): - schema = Schema('string', min_length=3) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [u(""), ]) - def test_string_max_length_invalid_schema(self, value, validator_factory): - schema = Schema('string', max_length=-1) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [u("ab"), u("abc")]) - def test_string_max_length_invalid(self, value, validator_factory): - schema = Schema('string', max_length=1) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [u(""), u("a")]) - def test_string_max_length(self, value, validator_factory): - schema = Schema('string', max_length=1) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [u("foo"), u("bar")]) - def test_string_pattern_invalid(self, value, validator_factory): - schema = Schema('string', pattern='baz') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [u("bar"), u("foobar")]) - def test_string_pattern(self, value, validator_factory): - schema = Schema('string', pattern='bar') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', ['true', False, 1, 3.14, [1, 3]]) - def test_object_not_an_object(self, value, validator_factory): - schema = Schema('object') - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [Model(), ]) - def test_object_multiple_one_of(self, value, validator_factory): - one_of = [ - Schema('object'), Schema('object'), - ] - schema = Schema('object', one_of=one_of) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [{}, ]) - def test_object_different_type_one_of(self, value, validator_factory): - one_of = [ - Schema('integer'), Schema('string'), - ] - schema = Schema('object', one_of=one_of) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [{}, ]) - def test_object_no_one_of(self, value, validator_factory): - one_of = [ - Schema( - 'object', - properties={'test1': Schema('string')}, - required=['test1', ], - ), - Schema( - 'object', - properties={'test2': Schema('string')}, - required=['test2', ], - ), - ] - schema = Schema('object', one_of=one_of) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - { - 'foo': u("FOO"), - }, - { - 'foo': u("FOO"), - 'bar': u("BAR"), - }, - ]) - def test_unambiguous_one_of(self, value, validator_factory): - one_of = [ - Schema( - 'object', - properties={ - 'foo': Schema('string'), - }, - additional_properties=False, - required=['foo'], - ), - Schema( - 'object', - properties={ - 'foo': Schema('string'), - 'bar': Schema('string'), - }, - additional_properties=False, - required=['foo', 'bar'], - ), - ] - schema = Schema('object', one_of=one_of) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [{}, ]) - def test_object_default_property(self, value, validator_factory): - schema = Schema('object', default='value1') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [{}, ]) - def test_object_min_properties_invalid_schema( - self, value, validator_factory): - schema = Schema('object', min_properties=2) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - {'a': 1}, - {'a': 1, 'b': 2}, - {'a': 1, 'b': 2, 'c': 3}, - ]) - def test_object_min_properties_invalid(self, value, validator_factory): - schema = Schema( - 'object', - properties={k: Schema('number') - for k in ['a', 'b', 'c']}, - min_properties=4, - ) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - {'a': 1}, - {'a': 1, 'b': 2}, - {'a': 1, 'b': 2, 'c': 3}, - ]) - def test_object_min_properties(self, value, validator_factory): - schema = Schema( - 'object', - properties={k: Schema('number') - for k in ['a', 'b', 'c']}, - min_properties=1, - ) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [{}, ]) - def test_object_max_properties_invalid_schema( - self, value, validator_factory): - schema = Schema('object', max_properties=-1) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - {'a': 1}, - {'a': 1, 'b': 2}, - {'a': 1, 'b': 2, 'c': 3}, - ]) - def test_object_max_properties_invalid(self, value, validator_factory): - schema = Schema( - 'object', - properties={k: Schema('number') - for k in ['a', 'b', 'c']}, - max_properties=0, - ) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - {'a': 1}, - {'a': 1, 'b': 2}, - {'a': 1, 'b': 2, 'c': 3}, - ]) - def test_object_max_properties(self, value, validator_factory): - schema = Schema( - 'object', - properties={k: Schema('number') - for k in ['a', 'b', 'c']}, - max_properties=3, - ) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [{'additional': 1}, ]) - def test_object_additional_properties(self, value, validator_factory): - schema = Schema('object') - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [{'additional': 1}, ]) - def test_object_additional_properties_false( - self, value, validator_factory): - schema = Schema('object', additional_properties=False) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [{'additional': 1}, ]) - def test_object_additional_properties_object( - self, value, validator_factory): - additional_properties = Schema('integer') - schema = Schema('object', additional_properties=additional_properties) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [[], [1], [1, 2]]) - def test_list_min_items_invalid(self, value, validator_factory): - schema = Schema( - 'array', - items=Schema('number'), - min_items=3, - ) - - with pytest.raises(Exception): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [[], [1], [1, 2]]) - def test_list_min_items(self, value, validator_factory): - schema = Schema( - 'array', - items=Schema('number'), - min_items=0, - ) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [[], ]) - def test_list_max_items_invalid_schema(self, value, validator_factory): - schema = Schema( - 'array', - items=Schema('number'), - max_items=-1, - ) - - with pytest.raises(InvalidSchemaValue): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [[1, 2], [2, 3, 4]]) - def test_list_max_items_invalid(self, value, validator_factory): - schema = Schema( - 'array', - items=Schema('number'), - max_items=1, - ) - - with pytest.raises(Exception): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [[1, 2, 1], [2, 2]]) - def test_list_unique_items_invalid(self, value, validator_factory): - schema = Schema( - 'array', - items=Schema('number'), - unique_items=True, - ) - - with pytest.raises(Exception): - validator_factory(schema).validate(value) - - @pytest.mark.parametrize('value', [ - { - 'someint': 123, - }, - { - 'somestr': u('content'), - }, - { - 'somestr': u('content'), - 'someint': 123, - }, - ]) - def test_object_with_properties(self, value, validator_factory): - schema = Schema( - 'object', - properties={ - 'somestr': Schema('string'), - 'someint': Schema('integer'), - }, - ) - - result = validator_factory(schema).validate(value) - - assert result is None - - @pytest.mark.parametrize('value', [ - { - 'somestr': {}, - 'someint': 123, - }, - { - 'somestr': [ - 'content1', 'content2' - ], - 'someint': 123, - }, - { - 'somestr': 123, - 'someint': 123, - }, - { - 'somestr': 'content', - 'someint': 123, - 'not_in_scheme_prop': 123, - }, - ]) - def test_object_with_invalid_properties(self, value, validator_factory): - schema = Schema( - 'object', - properties={ - 'somestr': Schema('string'), - 'someint': Schema('integer'), - }, - additional_properties=False, - ) - - with pytest.raises(Exception): - validator_factory(schema).validate(value) diff --git a/tests/unit/validation/test_request_shortcuts.py b/tests/unit/validation/test_request_shortcuts.py deleted file mode 100644 index 2b984cfb..00000000 --- a/tests/unit/validation/test_request_shortcuts.py +++ /dev/null @@ -1,143 +0,0 @@ -import mock - -import pytest - -from openapi_core.testing.datatypes import ResultMock -from openapi_core.testing.factories import FactoryClassMock -from openapi_core.validation.request.shortcuts import ( - spec_validate_parameters, spec_validate_body, -) - - -class TestSpecValidateParameters(object): - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_parameters' - ) - def test_no_request_factory(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - parameters = mock.sentinel.parameters - mock_validate.return_value = ResultMock(parameters=parameters) - - result = spec_validate_parameters(spec, request) - - assert result == parameters - mock_validate.aasert_called_once_with(request) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_parameters' - ) - def test_no_request_factory_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - - with pytest.raises(ValueError): - spec_validate_parameters(spec, request) - - mock_validate.aasert_called_once_with(request) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_parameters' - ) - def test_request_factory(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - parameters = mock.sentinel.parameters - mock_validate.return_value = ResultMock(parameters=parameters) - request_factory = FactoryClassMock - - result = spec_validate_parameters(spec, request, request_factory) - - assert result == parameters - mock_validate.assert_called_once_with( - FactoryClassMock(request), - ) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_parameters' - ) - def test_request_factory_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - request_factory = FactoryClassMock - - with pytest.raises(ValueError): - spec_validate_parameters(spec, request, request_factory) - - mock_validate.assert_called_once_with( - FactoryClassMock(request), - ) - - -class TestSpecValidateBody(object): - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_body' - ) - def test_no_request_factory(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - body = mock.sentinel.body - mock_validate.return_value = ResultMock(body=body) - - result = spec_validate_body(spec, request) - - assert result == body - mock_validate.aasert_called_once_with(request) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_body' - ) - def test_no_request_factory_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - - with pytest.raises(ValueError): - spec_validate_body(spec, request) - - mock_validate.aasert_called_once_with(request) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_body' - ) - def test_request_factory(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - body = mock.sentinel.body - mock_validate.return_value = ResultMock(body=body) - request_factory = FactoryClassMock - - result = spec_validate_body(spec, request, request_factory) - - assert result == body - mock_validate.assert_called_once_with( - FactoryClassMock(request), - ) - - @mock.patch( - 'openapi_core.validation.request.shortcuts.RequestValidator.' - '_validate_body' - ) - def test_request_factory_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - request_factory = FactoryClassMock - - with pytest.raises(ValueError): - spec_validate_body(spec, request, request_factory) - - mock_validate.assert_called_once_with( - FactoryClassMock(request), - ) diff --git a/tests/unit/validation/test_response_shortcuts.py b/tests/unit/validation/test_response_shortcuts.py deleted file mode 100644 index c42fc24e..00000000 --- a/tests/unit/validation/test_response_shortcuts.py +++ /dev/null @@ -1,88 +0,0 @@ -import mock - -import pytest - -from openapi_core.testing.datatypes import ResultMock -from openapi_core.testing.factories import FactoryClassMock -from openapi_core.validation.response.shortcuts import spec_validate_data - - -class TestSpecValidateData(object): - - @mock.patch( - 'openapi_core.validation.response.shortcuts.ResponseValidator.' - '_validate_data' - ) - def test_no_factories(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response - data = mock.sentinel.data - mock_validate.return_value = ResultMock(data=data) - - result = spec_validate_data(spec, request, response) - - assert result == data - mock_validate.aasert_called_once_with(request, response) - - @mock.patch( - 'openapi_core.validation.response.shortcuts.ResponseValidator.' - '_validate_data' - ) - def test_no_factories_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - - with pytest.raises(ValueError): - spec_validate_data(spec, request, response) - - mock_validate.aasert_called_once_with(request, response) - - @mock.patch( - 'openapi_core.validation.response.shortcuts.ResponseValidator.' - '_validate_data' - ) - def test_factories(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response - data = mock.sentinel.data - mock_validate.return_value = ResultMock(data=data) - request_factory = FactoryClassMock - response_factory = FactoryClassMock - - result = spec_validate_data( - spec, request, response, - request_factory, response_factory, - ) - - assert result == data - mock_validate.assert_called_once_with( - FactoryClassMock(request), - FactoryClassMock(response), - ) - - @mock.patch( - 'openapi_core.validation.response.shortcuts.ResponseValidator.' - '_validate_data' - ) - def test_factories_error(self, mock_validate): - spec = mock.sentinel.spec - request = mock.sentinel.request - response = mock.sentinel.response - mock_validate.return_value = ResultMock(error_to_raise=ValueError) - request_factory = FactoryClassMock - response_factory = FactoryClassMock - - with pytest.raises(ValueError): - spec_validate_data( - spec, request, response, - request_factory, response_factory, - ) - - mock_validate.assert_called_once_with( - FactoryClassMock(request), - FactoryClassMock(response), - ) diff --git a/tests/unit/validation/test_schema_validators.py b/tests/unit/validation/test_schema_validators.py new file mode 100644 index 00000000..4732a113 --- /dev/null +++ b/tests/unit/validation/test_schema_validators.py @@ -0,0 +1,215 @@ +import pytest +from jsonschema_path import SchemaPath + +from openapi_core.validation.schemas import ( + oas30_write_schema_validators_factory, +) +from openapi_core.validation.schemas.exceptions import InvalidSchemaValue + + +class TestSchemaValidate: + @pytest.fixture + def validator_factory(self): + def create_validator(schema): + return oas30_write_schema_validators_factory.create(schema) + + return create_validator + + def test_string_format_custom_missing(self, validator_factory): + custom_format = "custom" + schema = { + "type": "string", + "format": custom_format, + } + spec = SchemaPath.from_dict(schema) + value = "x" + + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [0, 1, 2]) + def test_integer_minimum_invalid(self, value, validator_factory): + schema = { + "type": "integer", + "minimum": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [4, 5, 6]) + def test_integer_minimum(self, value, validator_factory): + schema = { + "type": "integer", + "minimum": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [4, 5, 6]) + def test_integer_maximum_invalid(self, value, validator_factory): + schema = { + "type": "integer", + "maximum": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [0, 1, 2]) + def test_integer_maximum(self, value, validator_factory): + schema = { + "type": "integer", + "maximum": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [1, 2, 4]) + def test_integer_multiple_of_invalid(self, value, validator_factory): + schema = { + "type": "integer", + "multipleOf": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [3, 6, 18]) + def test_integer_multiple_of(self, value, validator_factory): + schema = { + "type": "integer", + "multipleOf": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [0, 1, 2]) + def test_number_minimum_invalid(self, value, validator_factory): + schema = { + "type": "number", + "minimum": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [3, 4, 5]) + def test_number_minimum(self, value, validator_factory): + schema = { + "type": "number", + "minimum": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [1, 2, 3]) + def test_number_exclusive_minimum_invalid(self, value, validator_factory): + schema = { + "type": "number", + "minimum": 3, + "exclusiveMinimum": True, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [4, 5, 6]) + def test_number_exclusive_minimum(self, value, validator_factory): + schema = { + "type": "number", + "minimum": 3, + "exclusiveMinimum": True, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [4, 5, 6]) + def test_number_maximum_invalid(self, value, validator_factory): + schema = { + "type": "number", + "maximum": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [1, 2, 3]) + def test_number_maximum(self, value, validator_factory): + schema = { + "type": "number", + "maximum": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [3, 4, 5]) + def test_number_exclusive_maximum_invalid(self, value, validator_factory): + schema = { + "type": "number", + "maximum": 3, + "exclusiveMaximum": True, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [0, 1, 2]) + def test_number_exclusive_maximum(self, value, validator_factory): + schema = { + "type": "number", + "maximum": 3, + "exclusiveMaximum": True, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None + + @pytest.mark.parametrize("value", [1, 2, 4]) + def test_number_multiple_of_invalid(self, value, validator_factory): + schema = { + "type": "number", + "multipleOf": 3, + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + validator_factory(spec).validate(value) + + @pytest.mark.parametrize("value", [3, 6, 18]) + def test_number_multiple_of(self, value, validator_factory): + schema = { + "type": "number", + "multipleOf": 3, + } + spec = SchemaPath.from_dict(schema) + + result = validator_factory(spec).validate(value) + + assert result is None