├── .aspell.en.pws ├── .coveragerc ├── .editorconfig ├── .envrc ├── .flake8 ├── .github ├── actions │ └── nix-shell │ │ └── action.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .yamllint ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASE.md ├── examples ├── singlefile │ ├── README.md │ ├── app.py │ └── shell.nix ├── splitfile │ ├── README.md │ ├── app.py │ ├── shell.nix │ ├── spec │ │ ├── openapi.yaml │ │ ├── paths.yaml │ │ ├── responses.yaml │ │ └── schemas.yaml │ └── tests.py └── todoapp │ ├── README.md │ ├── app.py │ ├── openapi.yaml │ ├── shell.nix │ └── tests.py ├── header.jpg ├── poetry.lock ├── poetry.toml ├── py310 ├── poetry.lock └── pyproject.toml ├── pyproject.toml ├── pyramid_openapi3 ├── __init__.py ├── exceptions.py ├── py.typed ├── static │ ├── index.html │ └── oauth2-redirect.html ├── tests │ ├── __init__.py │ ├── test_add_deserializer.py │ ├── test_add_formatter.py │ ├── test_add_unmarshaller.py │ ├── test_app_construction.py │ ├── test_contenttypes.py │ ├── test_extract_errors.py │ ├── test_path_parameters.py │ ├── test_permissions.py │ ├── test_routes.py │ ├── test_validation.py │ ├── test_views.py │ └── test_wrappers.py ├── tween.py └── wrappers.py └── shell.nix /.aspell.en.pws: -------------------------------------------------------------------------------- 1 | deriver 2 | connexion 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [report] 5 | # Regexes for lines to exclude from consideration 6 | exclude_lines = 7 | # Have to re-enable the standard pragma 8 | pragma\: no cover 9 | 10 | # Don't complain if non-runnable code isn't run: 11 | if __name__ == "__main__"\: 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | [*] 8 | # Indentation style 9 | # Possible values - tab, space 10 | indent_style = space 11 | 12 | # Indentation size in single-spaced characters 13 | # Possible values - an integer, tab 14 | indent_size = 2 15 | 16 | # Line ending file format 17 | # Possible values - lf, crlf, cr 18 | end_of_line = lf 19 | 20 | # File character encoding 21 | # Possible values - latin1, utf-8, utf-16be, utf-16le 22 | charset = utf-8 23 | 24 | # Denotes whether to trim whitespace at the end of lines 25 | # Possible values - true, false 26 | trim_trailing_whitespace = true 27 | 28 | # Denotes whether file should end with a newline 29 | # Possible values - true, false 30 | insert_final_newline = true 31 | 32 | # 4 space indentation 33 | [*.py] 34 | indent_size = 4 35 | 36 | # Tab indentation (no size specified) 37 | [Makefile] 38 | indent_style = tab 39 | indent_size = 4 40 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # Use shell.nix to build the development environment 2 | if [ -x "$(command -v lorri)" ]; then 3 | eval "$(lorri direnv)" 4 | else 5 | use nix 6 | fi 7 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.venv 3 | 4 | # allow up to 10% leeway 5 | max-line-length = 80 6 | select += B950 7 | 8 | ignore = 9 | # D202: No blank lines allowed after function docstring 10 | D202, 11 | # D204: 1 blank line required after class docstring 12 | D204, 13 | # D107: Missing docstring in __init__ 14 | D107, 15 | # W503: line break before binary operator 16 | W503, 17 | # E501: line too long (85 > 79 characters) - use B950 instead (10% leeway) 18 | E501 19 | -------------------------------------------------------------------------------- /.github/actions/nix-shell/action.yml: -------------------------------------------------------------------------------- 1 | name: "Prepare nix-shell" 2 | description: 3 | Download cache, build nix-shell and potentially upload any new 4 | derivations to cache 5 | 6 | inputs: 7 | cachix_auth_token: 8 | required: true 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: cachix/install-nix-action@v26 14 | with: 15 | nix_path: nixpkgs=channel:nixos-unstable 16 | - uses: cachix/cachix-action@v14 17 | with: 18 | name: pyramid-openapi3 19 | authToken: '${{ inputs.cachix_auth_token }}' 20 | 21 | - name: Build nix-shell 22 | shell: bash 23 | run: nix-shell --run "echo 'nix-shell successfully entered'" 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Run all tests, linters, code analysis and other QA tasks on 2 | # every push to master and PRs. 3 | 4 | name: CI 5 | 6 | on: 7 | workflow_dispatch: 8 | pull_request: 9 | push: 10 | branches: 11 | - main 12 | tags: 13 | - '*' 14 | 15 | # To SSH into the runner to debug a failure, add the following step before 16 | # the failing step 17 | # - uses: lhotari/action-upterm@v1 18 | # with: 19 | # limit-access-to-actor: true 20 | 21 | # Prevent multiple jobs running after fast subsequent pushes 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | 28 | test_312: 29 | name: "Python 3.12 Tests" 30 | 31 | runs-on: ubuntu-22.04 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: ./.github/actions/nix-shell 36 | with: 37 | cachix_auth_token: '${{ secrets.CACHIX_AUTH_TOKEN }}' 38 | 39 | - name: Run linters and unit tests 40 | run: | 41 | nix-shell --run "make lint all=true" 42 | nix-shell --run "make types" 43 | nix-shell --run "make unit" 44 | 45 | - name: Run tests for the singlefile example 46 | run: | 47 | cd examples/singlefile 48 | nix-shell --run "python -m unittest app.py" 49 | 50 | - name: Run tests for the todoapp example 51 | run: | 52 | cd examples/todoapp 53 | nix-shell --run "python -m unittest tests.py" 54 | 55 | - name: Run tests for the splitfile example 56 | run: | 57 | cd examples/splitfile 58 | nix-shell --run "python -m unittest tests.py" 59 | 60 | - name: Save coverage report 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: htmlcov 64 | path: htmlcov/ 65 | 66 | test_310: 67 | name: "Python 3.10 Tests" 68 | 69 | runs-on: ubuntu-22.04 70 | 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: ./.github/actions/nix-shell 74 | with: 75 | cachix_auth_token: '${{ secrets.CACHIX_AUTH_TOKEN }}' 76 | 77 | - name: Run linters and unit tests 78 | run: | 79 | nix-shell --run "PYTHON=python3.10 make lint all=true" 80 | nix-shell --run "PYTHON=python3.10 make types" 81 | nix-shell --run "PYTHON=python3.10 make unit" 82 | 83 | - name: Run tests for the singlefile example 84 | run: | 85 | cd examples/singlefile 86 | nix-shell --run "python3.10 -m unittest app.py" 87 | 88 | - name: Run tests for the todoapp example 89 | run: | 90 | cd examples/todoapp 91 | nix-shell --run "python3.10 -m unittest tests.py" 92 | 93 | - name: Run tests for the splitfile example 94 | run: | 95 | cd examples/splitfile 96 | nix-shell --run "python3.10 -m unittest tests.py" 97 | 98 | - name: Save coverage report 99 | uses: actions/upload-artifact@v4 100 | with: 101 | name: htmlcov-py310 102 | path: htmlcov/ 103 | 104 | 105 | release: 106 | name: "Release to PyPI" 107 | 108 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 109 | needs: [test_312, test_310] 110 | runs-on: ubuntu-22.04 111 | 112 | # To test publishing to testpypi: 113 | # * change version in pyproject.toml to 0.16.1-alpha.1 or similar 114 | # * uncomment POETRY_REPOSITORIES_TESTPYPI_URL and POETRY_PYPI_TOKEN_TESTPYPI 115 | # * append `-r testpypi` to poetry publish command 116 | # * `git ci && git tag 0.16.1-alpha.1 && git push --tags` 117 | 118 | env: 119 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.POETRY_PYPI_TOKEN_PYPI }} 120 | 121 | # POETRY_PYPI_TOKEN_TESTPYPI: ${{ secrets.POETRY_PYPI_TOKEN_TESTPYPI }} 122 | # POETRY_REPOSITORIES_TESTPYPI_URL: https://test.pypi.org/legacy/ 123 | 124 | permissions: 125 | contents: write 126 | 127 | steps: 128 | - uses: actions/checkout@v3 129 | - uses: ./.github/actions/nix-shell 130 | with: 131 | cachix_auth_token: '${{ secrets.CACHIX_AUTH_TOKEN }}' 132 | 133 | - name: verify git tag matches pyproject.toml version 134 | run: | 135 | echo "$GITHUB_REF_NAME" 136 | POETRY_VERSION=$(nix-shell --run "poetry version --short") 137 | echo $POETRY_VERSION 138 | 139 | [[ "$GITHUB_REF_NAME" == "$POETRY_VERSION" ]] && exit 0 || exit 1 140 | 141 | - run: nix-shell --run "poetry publish --build" 142 | 143 | - name: Create GitHub Release 144 | uses: softprops/action-gh-release@v2 145 | with: 146 | generate_release_notes: true 147 | make_latest: true 148 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | include/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | pip-selfcheck.json 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | # Sublime Markdown preview 109 | Readme.html 110 | 111 | # Coverage reports 112 | cov.xml 113 | htmltypecov/ 114 | typecov/ 115 | junit.xml 116 | 117 | # `make install` flag 118 | .installed 119 | 120 | # support for fast reloading direnv 121 | .direnv 122 | 123 | # Editors 124 | .vscode 125 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [commit,push] 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: pyupgrade 6 | name: pyupgrade 7 | description: Upgrades syntax for modern versions of Python. 8 | entry: pyupgrade 9 | language: system 10 | types: [python] 11 | args: [--py310-plus] 12 | 13 | - id: isort 14 | name: isort 15 | description: A Python utility that sorts imports alphabetically. 16 | entry: isort 17 | language: system 18 | types: [python] 19 | 20 | - id: autoflake 21 | name: autoflake 22 | description: Removes unused imports and unused variables from Python code. 23 | entry: autoflake 24 | language: system 25 | types: [python] 26 | 27 | - id: flake8 28 | name: Flake8 29 | description: Python Style Guide Enforcement. 30 | entry: flake8 --config .flake8 31 | language: system 32 | types: [python] 33 | 34 | - id: black 35 | name: Black 36 | description: Uncompromising Python code formatter. 37 | entry: black 38 | language: system 39 | types: [python] 40 | 41 | - id: trailing-whitespace 42 | name: Trim Trailing Space 43 | entry: trailing-whitespace-fixer 44 | language: system 45 | types: [file, text] 46 | 47 | - id: end-of-file-fixer 48 | name: Fix end of Files 49 | description: Ensures that a file is either empty, or ends with one newline. 50 | entry: end-of-file-fixer 51 | language: system 52 | types: [file, text] 53 | 54 | - id: check-merge-conflict 55 | name: Check for merge conflicts 56 | description: Check for files that contain merge conflict strings. 57 | entry: check-merge-conflict 58 | language: system 59 | types: [file, text] 60 | 61 | - id: codespell 62 | name: Check Spelling 63 | description: Checks for common misspellings in text files. 64 | entry: codespell --ignore-words .aspell.en.pws 65 | language: system 66 | types: [file, text] 67 | 68 | - id: yamllint 69 | name: yamllint 70 | description: Lint YAML files. 71 | entry: yamllint 72 | language: system 73 | types: [yaml] 74 | 75 | - id: nixpkg-fmt 76 | name: Format *.nix 77 | description: Format nix files. 78 | entry: nixpkgs-fmt 79 | language: system 80 | files: .*\.nix 81 | types: [non-executable, file, text] 82 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | rules: 2 | line-length: 3 | max: 88 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | For future releases, see 4 | 5 | --- 6 | 7 | 0.17.1 (2024-03-11) 8 | ------------------- 9 | 10 | * Fix `multipart/form-data` support, refs #225. 11 | [am-on] 12 | 13 | 14 | 0.17.0 (2024-03-08) 15 | ------------------- 16 | 17 | * Update the supported version of Python to 3.12, Pyramid to 2.0.2. 18 | Drop support for Python 3.8. 19 | [zupo] 20 | 21 | * Update the supported version of `openapi-core` to 0.19.0, refs #220. 22 | Drop support for all older versions of `openapi-core`. 23 | [miketheman, Wim-De-Clercq, zupo] 24 | 25 | * Update Swagger UI version to 4.18.3, refs #210. 26 | [kskarthik] 27 | 28 | * Add support for specifying the protocol and port for getting the openapi3 29 | spec file, fixes running behind a reverse proxy, refs #176. 30 | [vpet98, zupo] 31 | 32 | 33 | 0.16.0 (2023-03-22) 34 | ------------------- 35 | 36 | * Add support for setting a prefix for auto-generated routes. 37 | [zupo] 38 | 39 | 40 | 0.15.0 (2022-12-11) 41 | ------------------- 42 | 43 | * Update Swagger UI version to 4.15.3, refs #185. 44 | [kskarthik] 45 | 46 | * Upgrade `openapi-core dependency` to `0.16`, refs #173. 47 | 48 | [BREAKING CHANGE] `request.openapi_validated.parameters` will now be 49 | a dataclass instead of a dict. For example: if you wanted to get a 50 | query parameter called `limit`: 51 | * before: `request.openapi_validated.parameters["query"]["limit"]` 52 | * after: `request.openapi_validated.parameters.query["limit"]` 53 | [damonhook] 54 | 55 | 56 | 0.14.3 (2022-11-29) 57 | ------------------- 58 | 59 | * `request.openapi_validated` no longer breaks non-opeanpi views, refs #172. 60 | [grinspins, zupo] 61 | 62 | 63 | 0.14.2 (2022-11-17) 64 | ------------------- 65 | 66 | * Remove openapi-schema-validator as a dependency, as it is already pulled in 67 | by openapi-core. 68 | [zupo] 69 | 70 | 71 | 0.14.1 (2022-11-17) 72 | ------------------- 73 | 74 | * Cleanup and modernization of dev env, added support for Python 3.10 and 3.11. 75 | [zupo] 76 | 77 | 78 | 0.14 (2022-05-05) 79 | ----------------- 80 | 81 | * Upgrade Swagger UI to latest version to get security fixes missing in 82 | the old release. 83 | [am-on] 84 | 85 | 86 | 0.13 (2021-06-13) 87 | ----------------- 88 | 89 | * Add support for Pyramid 2.0 refs #133, #142. 90 | [lkuchenb, zupo] 91 | 92 | 93 | 0.12 (2021-06-07) 94 | ----------------- 95 | 96 | * Basic support for multipart requests, refs #122. 97 | [Wim-De-Clercq] 98 | 99 | * Automatic route registration via `x-pyramid-route-name` extension, refs #46. 100 | [gjo] 101 | 102 | * Support for Python 3.9, refs #115. 103 | [stevepiercy, zupo] 104 | 105 | * Cleanup old relative OpenAPI server URL workaround. Drop support for 106 | `openapi-core 0.13.1`, refs #127, #129, #131. 107 | [sevanteri, zupo] 108 | 109 | * Fix `KeyError` when having multiple routes for a single path, refs #118. 110 | [damonhook] 111 | 112 | 113 | 0.11 (2021-02-15) 114 | ----------------- 115 | 116 | * Allow setting permission for explorer and spec view. 117 | [sweh] 118 | 119 | * Allow multiple OpenApis in one pyramid application. 120 | [sweh] 121 | 122 | * Fix for route validation when used with pyramid route_prefix_context. 123 | [damonhook] 124 | 125 | 126 | 0.10.2 (2020-10-27) 127 | ------------------ 128 | 129 | * Support for endpoint validation of prefixed routes. 130 | [zupo] 131 | 132 | 133 | 0.10.1 (2020-10-26) 134 | ------------------ 135 | 136 | * Support disabling of endpoint validation via INI files. 137 | [zupo] 138 | 139 | 140 | 0.10.0 (2020-10-26) 141 | ------------------ 142 | 143 | * Allow relative file `$ref` links in OpenApi spec, refs #93. 144 | [damonhook] 145 | 146 | * Validate that all endpoints in the spec have a registered route, refs #21. 147 | [phrfpeixoto] 148 | 149 | 150 | 0.9.0 (2020-08-16) 151 | ------------------ 152 | 153 | * Add support for openapi-core 0.13.4. 154 | [sjiekak] 155 | 156 | * Add the ability to toggle request/response validation independently through 157 | registry settings. 158 | [damonhook] 159 | 160 | 161 | 0.8.3 (2020-06-21) 162 | ------------------ 163 | 164 | * Brown-bag release. 165 | [zupo] 166 | 167 | 168 | 0.8.2 (2020-06-21) 169 | ------------------ 170 | 171 | * Raise a warning when a bad API spec causes validation errors to be discarded. 172 | [matthewwilkes] 173 | 174 | * Fix `custom_formatters` support in latest openapi-core 0.13.3. 175 | [simondale00] 176 | 177 | * Declare a minimal supported version of openapi-core. 178 | [zupo] 179 | 180 | 181 | 0.8.1 (2020-05-03) 182 | ------------------ 183 | 184 | * Fix extract_errors to support lists, refs #75. 185 | [zupo] 186 | 187 | 188 | 0.8.0 (2020-04-20) 189 | ------------------ 190 | 191 | * Log Response validation errors as errors, instead of warnings. 192 | [zupo] 193 | 194 | * Log Request validation errors as warnings, instead of infos. 195 | [zupo] 196 | 197 | 198 | 0.7.0 (2020-04-03) 199 | ------------------ 200 | 201 | * Better support for handling apps mounted at subpaths. 202 | [mmerickel] 203 | 204 | * Pass the response into the response validation exception to support use-cases 205 | where we can return the response but log the errors. 206 | [mmerickel] 207 | 208 | * Reload development server also when YAML file changes. 209 | [mmerickel] 210 | 211 | 212 | 0.6.0 (2020-03-19) 213 | ------------------ 214 | 215 | * Better support for custom formatters and a test showcasing how to use them. 216 | [zupo] 217 | 218 | 219 | 0.5.2 (2020-03-16) 220 | ------------------ 221 | 222 | * Bad JWT tokens should result in 401 instead of 400. 223 | [zupo] 224 | 225 | 226 | 0.5.1 (2020-03-13) 227 | ------------------ 228 | 229 | * Fix a regression with relative `servers` entries in `openapi.yaml`. 230 | Refs https://github.com/p1c2u/openapi-core/issues/218. 231 | [zupo] 232 | 233 | 234 | 0.5.0 (2020-03-07) 235 | ------------------ 236 | 237 | * [BREAKING CHANGE] Move `openapi_validation_error` from `examples/todoapp` 238 | into the main package so it becomes a first-class citizen and people can use 239 | it without copy/pasting. If you need custom JSON rendering, you can provide 240 | your own `extract_errors` function via `pyramid_openapi3_extract_errors` 241 | config setting. 242 | [zupo] 243 | 244 | * Upgrade `openapi-core` to `0.13.x` which brings a complete rewrite of the 245 | validation mechanism that is now based on `jsonschema` library. This 246 | manifests as different validation error messages. 247 | 248 | [BREAKING CHANGE] By default, `openapi-core` no longer creates models 249 | from validated data, but returns `dict`s. More info on 250 | https://github.com/p1c2u/openapi-core/issues/205 251 | [zupo] 252 | 253 | 254 | 0.4.1 (2019-10-22) 255 | ------------------ 256 | 257 | * Pin openapi-core dependency to a sub 0.12.0 version, to avoid 258 | regressions with validation. Details on 259 | https://github.com/p1c2u/openapi-core/issues/160 260 | [zupo] 261 | 262 | 263 | 0.4.0 (2019-08-05) 264 | ------------------ 265 | 266 | * Fix handling parameters in Headers and Cookies. [gweis] 267 | 268 | * Introduce RequestValidationError and ResponseValidationError exceptions 269 | in favor of pyramid_openapi3_validation_error_view directive. 270 | [gweis] 271 | 272 | 273 | 0.3.0 (2019-05-22) 274 | ------------------ 275 | 276 | * Added type hints. [zupo] 277 | * Added additional references to other packages covering the same problem-space. [zupo] 278 | * Moved repo to Pylons GitHub organization. [stevepiercy, zupo] 279 | * Added a more built-out TODO-app example. [zupo] 280 | 281 | 282 | 0.2.8 (2019-04-17) 283 | ------------------ 284 | 285 | * Fix for double-registering views. [zupo] 286 | * Added a single-file example. [zupo] 287 | 288 | 289 | 0.2.7 (2019-04-14) 290 | ------------------ 291 | 292 | * Tweaking the release process. [zupo] 293 | 294 | 295 | 0.2.6 (2019-04-14) 296 | ------------------ 297 | 298 | * Added a bunch of tests. [zupo] 299 | 300 | 301 | 0.2.5 (2019-04-08) 302 | ------------------ 303 | 304 | * Automatic releases via CircleCI. [zupo] 305 | 306 | 307 | 0.1.0 (2019-04-08) 308 | ------------------ 309 | 310 | * Initial release. [zupo] 311 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All projects under the Pylons Project, including this one, follow the guidelines established at [How to Contribute](https://pylonsproject.org/community-how-to-contribute.html), [Coding Style and Standards](https://pylonsproject.org/community-coding-style-standards.html), and [Pylons Project Documentation Style Guide](https://docs.pylonsproject.org/projects/docs-style-guide/). 4 | 5 | You can contribute to this project in several ways. 6 | 7 | * [File an Issue on GitHub](https://github.com/Pylons/pyramid_openapi3/issues) 8 | * Fork this project, create a new branch, commit your suggested change, and push to your fork on GitHub. 9 | When ready, submit a pull request for consideration. 10 | [GitHub Flow](https://guides.github.com/introduction/flow/index.html) describes the workflow process and why it's a good practice. 11 | * Join the [IRC channel #pyramid on irc.freenode.net](https://webchat.freenode.net/?channels=pyramid). 12 | 13 | ## Git Branches 14 | 15 | Git branches and their purpose and status at the time of this writing are listed below. 16 | 17 | * [main](https://github.com/Pylons/pyramid_openapi3/) - The branch which should always be *deployable*. The default branch on GitHub. 18 | * For development, create a new branch. If changes on your new branch are accepted, they will be merged into the main branch and deployed. 19 | 20 | ## Prerequisites 21 | 22 | Follow the instructions in [README.rst](https://github.com/Pylons/pyramid_openapi3/) to install the tools needed to run the project. 23 | 24 | ## Testing oldest supported versions 25 | 26 | In CI, we want to test the oldest supported versions of `openapi-core` and `pyramid` on the oldest supported Python version. We do it like so: 27 | 28 | * Have the `py39` folder with additional `pyproject.toml` and `poetry.lock` files 29 | that are changed to pin `openapi-core` and `pyramid` to minimally supported version. 30 | * They are auto-generated when running `make lock`. 31 | * They are used by Nix to prepare the Python 3.10 env. 32 | * `PYTHON=python3.10 make tests` then run tests with an older Python version. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present Niteo GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md LICENSE 2 | graft pyramid_openapi3 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Convenience makefile to build the dev env and run common commands 2 | # Based on https://github.com/niteoweb/Makefile 3 | 4 | PYTHON ?= python3.12 5 | 6 | .PHONY: all 7 | all: tests 8 | 9 | # Lock version pins for Python dependencies 10 | .PHONY: lock 11 | lock: 12 | @rm -rf .venv/ 13 | @poetry lock --no-update 14 | @rm -rf .venv/ 15 | @nix-shell --run true 16 | @direnv reload 17 | @cat pyproject.toml \ 18 | | sed 's/openapi-core = ">=/openapi-core = "==/g' \ 19 | | sed 's/pyramid = ">=/pyramid = "==/g' \ 20 | > py310/pyproject.toml 21 | @rm -rf .venv/ 22 | @poetry lock --no-update --directory py310 23 | @rm -rf .venv/ 24 | @nix-shell --run true 25 | @direnv reload 26 | 27 | # Testing and linting targets 28 | all = false 29 | 30 | .PHONY: lint 31 | lint: 32 | # 1. get all unstaged modified files 33 | # 2. get all staged modified files 34 | # 3. get all untracked files 35 | # 4. run pre-commit checks on them 36 | ifeq ($(all),true) 37 | @pre-commit run --hook-stage push --all-files 38 | else 39 | @{ git diff --name-only ./; git diff --name-only --staged ./;git ls-files --other --exclude-standard; } \ 40 | | sort -u | uniq | xargs pre-commit run --hook-stage push --files 41 | endif 42 | 43 | .PHONY: type 44 | type: types 45 | 46 | .PHONY: types 47 | types: . 48 | @mypy examples/todoapp 49 | @cat ./typecov/linecount.txt 50 | @typecov 100 ./typecov/linecount.txt 51 | @mypy pyramid_openapi3 52 | @cat ./typecov/linecount.txt 53 | @typecov 100 ./typecov/linecount.txt 54 | 55 | 56 | # anything, in regex-speak 57 | filter = "." 58 | 59 | # additional arguments for pytest 60 | full_suite = "false" 61 | ifeq ($(filter),".") 62 | full_suite = "true" 63 | endif 64 | ifdef path 65 | full_suite = "false" 66 | endif 67 | args = "" 68 | pytest_args = -k $(filter) $(args) 69 | ifeq ($(args),"") 70 | pytest_args = -k $(filter) 71 | endif 72 | verbosity = "" 73 | ifeq ($(full_suite),"false") 74 | verbosity = -vv 75 | endif 76 | full_suite_args = "" 77 | ifeq ($(full_suite),"true") 78 | full_suite_args = --junitxml junit.xml --durations 10 --cov=pyramid_openapi3 --cov-branch --cov-report html --cov-report xml:cov.xml --cov-report term-missing --cov-fail-under=100 79 | endif 80 | 81 | 82 | .PHONY: unit 83 | unit: 84 | ifndef path 85 | @$(PYTHON) -m pytest pyramid_openapi3 $(verbosity) $(full_suite_args) $(pytest_args) 86 | else 87 | @$(PYTHON) -m pytest $(path) 88 | endif 89 | 90 | .PHONY: test 91 | test: tests 92 | 93 | .PHONY: tests 94 | tests: lint types unit 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyramid_openapi3 2 | 3 | ## Validate [Pyramid](https://trypyramid.com) views against [OpenAPI 3.0/3.1](https://swagger.io/specification/) documents 4 | 5 |

6 | Pyramid and OpenAPI logos 9 |

10 | 11 |

12 | 13 | CI for pyramid_openapi3 (main branch) 15 | 16 | Test coverage (main branch) 18 | Test coverage (main branch) 20 | 21 | latest version of pyramid_openapi3 on PyPI 23 | 24 | 25 | Supported Python versions 27 | 28 | 29 | License: MIT 31 | 32 | 33 | Built by these great folks! 35 | 36 |

37 | 38 | ## Peace of Mind 39 | 40 | The reason this package exists is to give you peace of mind when providing a RESTful API. Instead of chasing down preventable bugs and saying sorry to consumers, you can focus on more important things in life. 41 | 42 | - Your **API documentation is never out-of-date**, since it is generated out of the API document that you write. 43 | - The documentation comes with **_try-it-out_ examples** for every endpoint in your API. You don't have to provide (and maintain) `curl` commands to showcase how your API works. Users can try it themselves, right in their browsers. 44 | - Your **API document is always valid**, since your Pyramid app won't even start if the document does not comply with the OpenAPI 3.0 specification. 45 | - Automatic request **payload validation and sanitization**. Your views do not require any code for validation and input sanitation. Your view code only deals with business logic. Tons of tests never need to be written since every request, and its payload, is validated against your API document before it reaches your view code. 46 | - Your API **responses always match your API document**. Every response from your view is validated against your document and a `500 Internal Server Error` is returned if the response does not exactly match what your document says the output of a certain API endpoint should be. This decreases the effects of [Hyrum's Law](https://www.hyrumslaw.com). 47 | - **A single source of truth**. Because of the checks outlined above, you can be sure that whatever your API document says is in fact what is going on in reality. You have a single source of truth to consult when asking an API related question, such as "Remind me again, which fields are returned by the endpoint `/user/info`?". 48 | - Based on [Pyramid](https://trypyramid.com), a **mature Python Web framework**. Companies such as Mozilla, Yelp, RollBar and SurveyMonkey [trust Pyramid](https://trypyramid.com/community-powered-by-pyramid.html), and the new [pypi.org](https://github.com/pypa/warehouse) runs on Pyramid, too. Pyramid is thoroughly [tested](https://github.com/Pylons/Pyramid/actions?query=workflow%3A%22Build+and+test%22) and [documented](https://docs.pylonsproject.org/projects/pyramid/en/latest/), providing flexibility, performance, and a large ecosystem of [high-quality add-ons](https://trypyramid.com/extending-pyramid.html). 49 | 50 |

51 | Building Robust APIs 52 |

53 | 54 | ## Features 55 | 56 | - Validates your API document (for example, `openapi.yaml` or `openapi.json`) against the OpenAPI 3.0 specification using the [openapi-spec-validator](https://github.com/p1c2u/openapi-spec-validator). 57 | - Generates and serves the [Swagger try-it-out documentation](https://swagger.io/tools/swagger-ui/) for your API. 58 | - Validates incoming requests _and_ outgoing responses against your API document using [openapi-core](https://github.com/p1c2u/openapi-core). 59 | 60 | ## Getting started 61 | 62 | 1. Declare `pyramid_openapi3` as a dependency in your Pyramid project. 63 | 64 | 2. Include the following lines: 65 | 66 | ```python 67 | config.include("pyramid_openapi3") 68 | config.pyramid_openapi3_spec('openapi.yaml', route='/api/v1/openapi.yaml') 69 | config.pyramid_openapi3_add_explorer(route='/api/v1/') 70 | ``` 71 | 72 | 3. Use the `openapi` [view predicate](https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/viewconfig.html#view-configuration-parameters) to enable request/response validation: 73 | 74 | ```python 75 | @view_config(route_name="foobar", openapi=True, renderer='json') 76 | def myview(request): 77 | return request.openapi_validated.parameters 78 | ``` 79 | 80 | For requests, `request.openapi_validated` is available with two fields: `parameters` and `body`. 81 | For responses, if the payload does not match the API document, an exception is raised. 82 | 83 | ## Advanced configuration 84 | 85 | ### Relative File References in Spec 86 | 87 | A feature introduced in OpenAPI3 is the ability to use `$ref` links to external files (). 88 | 89 | To use this, you must ensure that you have all of your spec files in a given directory (ensure that you do not have any code in this directory as all the files in it are exposed as static files), then **replace** the `pyramid_openapi3_spec` call that you did in [Getting Started](#getting-started) with the following: 90 | 91 | ```python 92 | config.pyramid_openapi3_spec_directory('path/to/openapi.yaml', route='/api/v1/spec') 93 | ``` 94 | 95 | Some notes: 96 | 97 | - Do not set the `route` of your `pyramid_openapi3_spec_directory` to the same value as the `route` of `pyramid_openapi3_add_explorer`. 98 | - The `route` that you set for `pyramid_openapi3_spec_directory` should not contain any file extensions, as this becomes the root for all of the files in your specified `filepath`. 99 | - You cannot use `pyramid_openapi3_spec_directory` and `pyramid_openapi3_spec` in the same app. 100 | 101 | ### Endpoints / Request / Response Validation 102 | 103 | Provided with `pyramid_openapi3` are a few validation features: 104 | 105 | - incoming request validation (i.e., what a client sends to your app) 106 | - outgoing response validation (i.e., what your app sends to a client) 107 | - endpoint validation (i.e., your app registers routes for all defined API endpoints) 108 | 109 | These features are enabled as a default, but you can disable them if you need to: 110 | 111 | ```python 112 | config.registry.settings["pyramid_openapi3.enable_endpoint_validation"] = False 113 | config.registry.settings["pyramid_openapi3.enable_request_validation"] = False 114 | config.registry.settings["pyramid_openapi3.enable_response_validation"] = False 115 | ``` 116 | 117 | > [!WARNING] 118 | > Disabling request validation will result in `request.openapi_validated` no longer being available to use. 119 | 120 | ### Register Pyramid's Routes 121 | 122 | You can register routes in your pyramid application. 123 | First, write the `x-pyramid-route-name` extension in the PathItem of the OpenAPI schema. 124 | 125 | ```yaml 126 | paths: 127 | /foo: 128 | x-pyramid-route-name: foo_route 129 | get: 130 | responses: 131 | 200: 132 | description: GET foo 133 | ``` 134 | 135 | Then put the config directive `pyramid_openapi3_register_routes` in the app_factory of your application. 136 | 137 | ```python 138 | config.pyramid_openapi3_register_routes() 139 | ``` 140 | 141 | This is equal to manually writing the following: 142 | 143 | ```python 144 | config.add_route("foo_route", pattern="/foo") 145 | ``` 146 | 147 | The `pyramid_openapi3_register_routes()` method supports setting a factory and route prefix as well. See the source for details. 148 | 149 | ### Specify protocol and port for getting the OpenAPI 3 spec file 150 | 151 | Sometimes, it is necessary to specify the protocol and port to access the openapi3 spec file. This can be configured using the `proto_port` optional parameter to the the `pyramid_openapi3_add_explorer` function: 152 | 153 | ```python 154 | config.pyramid_openapi3_add_explorer(proto_port=('https', 443)) 155 | ``` 156 | 157 | ## Demo / Examples 158 | 159 | There are three examples provided with this package: 160 | 161 | - A fairly simple [single-file app providing a Hello World API](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/singlefile). 162 | - A slightly more [built-out app providing a TODO app API](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/todoapp). 163 | - Another TODO app API, defined using a [YAML spec split into multiple files](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/splitfile). 164 | 165 | All examples come with tests that exhibit pyramid_openapi's error handling and validation capabilities. 166 | 167 | A **fully built-out app**, with 100% test coverage, providing a [RealWorld.io](https://realworld.io) API is available at [niteoweb/pyramid-realworld-example-app](https://github.com/niteoweb/pyramid-realworld-example-app). It is a Heroku-deployable Pyramid app that provides an API for a Medium.com-like social app. You are encouraged to use it as a scaffold for your next project. 168 | 169 | ## Design defense 170 | 171 | The authors of pyramid_openapi3 believe that the approach of validating a manually-written API document is superior to the approach of generating the API document from Python code. Here are the reasons: 172 | 173 | 1. Both generation and validation against a document are lossy processes. The underlying libraries running the generation/validation will always have something missing. Either a feature from the latest OpenAPI specification, or an implementation bug. Having to fork the underlying library in order to generate the part of your API document that might only be needed for the frontend is unfortunate. 174 | 175 | Validation on the other hand allows one to skip parts of validation that are not supported yet, and not block a team from shipping the document. 176 | 177 | 2. The validation approach does sacrifice DRY-ness, and one has to write the API document and then the (view) code in Pyramid. It feels a bit redundant at first. However, this provides a clear separation between the intent and the implementation. 178 | 179 | 3. The generation approach has the drawback of having to write Python code even for parts of the API document that the Pyramid backend does not handle, as it might be handled by a different system, or be specific only to documentation or only to the client side of the API. This bloats your Pyramid codebase with code that does not belong there. 180 | 181 | ## Running tests 182 | 183 | You need to have [poetry](https://python-poetry.org/) and Python 3.10 & 3.12 installed on your machine. All `Makefile` commands assume you have the Poetry environment activated, i.e. `poetry shell`. 184 | 185 | Alternatively, if you use [nix](https://nix.dev/tutorials/declarative-and-reproducible-developer-environments), run `nix-shell` to drop into a shell that has everything prepared for development. 186 | 187 | Then you can run: 188 | 189 | ```shell 190 | make tests 191 | ``` 192 | 193 | ## Related packages 194 | 195 | These packages tackle the same problem-space: 196 | 197 | - [pyramid_oas3](https://github.com/kazuki/pyramid-oas3) seems to do things very similarly to pyramid_openapi3, but the documentation is not in English and we sadly can't fully understand what it does by just reading the code. 198 | - [pyramid_swagger](https://github.com/striglia/pyramid_swagger) does a similar 199 | thing, but for Swagger 2.0 documents. 200 | - [connexion](https://github.com/zalando/connexion) takes the same "write spec first, code second" approach as pyramid_openapi3, but is based on Flask. 201 | - [bottle-swagger](https://github.com/ampedandwired/bottle-swagger) takes the same "write spec first, code second" approach too, but is based on Bottle. 202 | - [pyramid_apispec](https://github.com/ergo/pyramid_apispec) uses generation with 203 | help of apispec and the marshmallow validation library. See above [why we prefer validation instead of generation](#design-defense). 204 | 205 | ## Deprecation policy 206 | 207 | We do our best to follow the rules below. 208 | 209 | - Support the latest few releases of Python, currently Python 3.10 through 3.12. 210 | - Support the latest few releases of Pyramid, currently 1.10.7 through 2.0.2. 211 | - Support the latest few releases of `openapi-core`, currently just 0.19.0. 212 | - See `poetry.lock` for a frozen-in-time known-good-set of all dependencies. 213 | 214 | ## Use in the wild 215 | 216 | A couple of projects that use pyramid_openapi3 in production: 217 | 218 | - [Pareto Security Team Dashboard API](https://dash.paretosecurity.com/api/v1) - Team Dashboard for Pareto Security macOS security app. 219 | - [SEO Domain Finder API](https://app.seodomainfinder.com/api/v1) - A tool for finding expired domains with SEO value. 220 | - [Rankalyzer.io](https://app.rankalyzer.io/api/v1) - Monitor SEO changes and strategies of competitors to improve your search traffic. 221 | - [Kafkai API](https://app.kafkai.com/api/v1) - User control panel for Kafkai text generation service. 222 | - [Open on-chain data API](https://tradingstrategy.ai/api/explorer/) - Decentralised exchange and blockchain trading data open API. 223 | - [Mayet RX](https://app.mayetrx.com/api/v1) - Vendor management system for pharma/medical clinical trials. 224 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to release a new version 2 | 3 | 1. Set the new version in `pyproject.toml`. 4 | 1. `make lock` 5 | 1. `make tests` 6 | 1. `export VERSION=` 7 | 1. `git add -p && git ci -m "release $VERSION"` 8 | 1. `git push origin main` and wait for GitHub Actions to pass the build. 9 | 1. `git tag $VERSION` 10 | 1. `git push --tags` 11 | 12 | The Action should build & test the package, and then upload it to PyPI. 13 | Then, automatically create a new GitHub Release with generated changelog. After 14 | the Action is done, go to https://github.com/Pylons/pyramid_openapi3/releases/, 15 | and edit the latest release to remove cleanup/unnecessary items from the 16 | description. 17 | -------------------------------------------------------------------------------- /examples/singlefile/README.md: -------------------------------------------------------------------------------- 1 | # An single-file example RESTful API app showcasing the power of pyramid_openapi3 2 | 3 | The `app.py` file in this folder showcases how to use the [pyramid_openapi3](https://github.com/Pylons/pyramid_openapi3) Pyramid add-on for building robust RESTful APIs. With only a few lines of code you get automatic validation of requests and responses against an OpenAPI v3 schema, along with Swagger "try-it-out" documentation for your API. 4 | 5 | ## How to run 6 | 7 | ```bash 8 | * git clone https://github.com/Pylons/pyramid_openapi3.git 9 | * cd pyramid_openapi3/examples/singlefile 10 | * virtualenv -p python3.10 . 11 | * source bin/activate 12 | * pip install pyramid_openapi3 13 | * python app.py 14 | ``` 15 | 16 | Then use the Swagger interface at http://localhost:6543/docs/ to discover the API. Use the `Try it out` button to run through a few request/response scenarios. 17 | 18 | For example: 19 | * Say Hello by doing a GET request to `http://localhost:6543/hello?name=john`. 20 | * Get an Exception raised if you omit the required `name` query parameter. 21 | * Get an Exception raised if the query parameter `name` is too short. 22 | 23 | All of these examples are covered with tests that you can run with `$ python -m unittest app.py`. The tests need the Python webtest module installed. If your virtualenv is still activated you can install it with `pip install webtest`. 24 | 25 | 26 | ## Further read 27 | z 28 | * A slightly more complex, multi-file example is available in the [`examples/todoapp`](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/todoapp) folder. 29 | 30 | * A fully built-out app, with 100% test coverage, providing a [RealWorld.io](https://realworld.io) API is available at [niteoweb/pyramid-realworld-example-app](https://github.com/niteoweb/pyramid-realworld-example-app). It is a Heroku-deployable Pyramid app that provides an API for a Medium.com-like social app. You are encouraged to use it as a scaffold for your next project. 31 | 32 | * More information about the library providing the integration between OpenAPI specs and Pyramid, more advanced features and design defence, is available in the main [README](https://github.com/Pylons/pyramid_openapi3) file. 33 | 34 | * More validators for fields are listed in the [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#properties) document. You can use Regex as well. 35 | 36 | * For an idea of a fully-fledged production OpenApi specification, check out [WooCart's OpenAPI docs](https://app.woocart.com/api/v1/). 37 | -------------------------------------------------------------------------------- /examples/singlefile/app.py: -------------------------------------------------------------------------------- 1 | """A single-file demo of pyramid_openapi3. 2 | 3 | See README.md at 4 | https://github.com/Pylons/pyramid_openapi3/tree/main/examples/singlefile 5 | """ 6 | 7 | from pyramid.config import Configurator 8 | from pyramid.httpexceptions import HTTPForbidden 9 | from pyramid.view import view_config 10 | from wsgiref.simple_server import make_server 11 | 12 | import tempfile 13 | import unittest 14 | 15 | # This is usually in a separate openapi.yaml file, but for the sake of the 16 | # example we want everything in a single file. Other examples have it nicely 17 | # separated. 18 | OPENAPI_DOCUMENT = b""" 19 | openapi: "3.1.0" 20 | info: 21 | version: "1.0.0" 22 | title: Hello API 23 | paths: 24 | /hello: 25 | get: 26 | parameters: 27 | - name: name 28 | in: query 29 | required: true 30 | schema: 31 | type: string 32 | minLength: 3 33 | responses: 34 | 200: 35 | description: Say hello 36 | 400: 37 | description: Bad Request 38 | """ 39 | 40 | 41 | @view_config(route_name="hello", renderer="json", request_method="GET", openapi=True) 42 | def hello(request): 43 | """Say hello.""" 44 | if request.openapi_validated.parameters.query["name"] == "admin": 45 | raise HTTPForbidden() 46 | return {"hello": request.openapi_validated.parameters.query["name"]} 47 | 48 | 49 | def app(spec): 50 | """Prepare a Pyramid app.""" 51 | with Configurator() as config: 52 | config.include("pyramid_openapi3") 53 | config.pyramid_openapi3_spec(spec) 54 | config.pyramid_openapi3_add_explorer() 55 | config.add_route("hello", "/hello") 56 | config.scan(".") 57 | return config.make_wsgi_app() 58 | 59 | 60 | if __name__ == "__main__": 61 | """If app.py is called directly, start up the app.""" 62 | with tempfile.NamedTemporaryFile() as document: 63 | document.write(OPENAPI_DOCUMENT) 64 | document.seek(0) 65 | 66 | print("visit api explorer at http://0.0.0.0:6543/docs/") # noqa: T201 67 | server = make_server("0.0.0.0", 6543, app(document.name)) 68 | server.serve_forever() 69 | 70 | 71 | ####################################### 72 | # ---- Tests ---- # 73 | # A couple of functional tests to # 74 | # showcase pyramid_openapi3 features. # 75 | # Usage: python -m unittest app.py # 76 | ####################################### 77 | 78 | 79 | class FunctionalTests(unittest.TestCase): 80 | """A suite of tests that make actual requests to a running app.""" 81 | 82 | def setUp(self): 83 | """Start up the app so that tests can send requests to it.""" 84 | from webtest import TestApp 85 | 86 | with tempfile.NamedTemporaryFile() as document: 87 | document.write(OPENAPI_DOCUMENT) 88 | document.seek(0) 89 | 90 | self.testapp = TestApp(app(document.name)) 91 | 92 | def test_nothing_on_root(self): 93 | """We have not configured our app to serve anything on root.""" 94 | res = self.testapp.get("/", status=404) 95 | self.assertIn("404 Not Found", res.text) 96 | 97 | def test_api_explorer_on_docs(self): 98 | """Swagger's API Explorer should be served on /docs/.""" 99 | res = self.testapp.get("/docs/", status=200) 100 | self.assertIn("Swagger UI", res.text) 101 | 102 | def test_hello(self): 103 | """Say hello.""" 104 | res = self.testapp.get("/hello?name=john", status=200) 105 | self.assertIn('{"hello": "john"}', res.text) 106 | 107 | def test_undefined_response(self): 108 | """Saying hello to admin should fail with 403 Forbidden. 109 | 110 | But because we have not defined how a 403 response should look in 111 | OPENAPI_DOCUMENT, we instead get an error 500 response, because it is 112 | the servers fault to generate an invalid response. 113 | 114 | This is to prevent us from forgetting to define all possible responses 115 | in our openapi document. 116 | """ 117 | res = self.testapp.get("/hello?name=admin", status=500) 118 | self.assertIn("Unknown response http status: 403", res.text) 119 | 120 | def test_name_missing(self): 121 | """Our view code does not even get called is request is not per-spec. 122 | 123 | We don't have to write (and test!) any validation code in our view! 124 | """ 125 | res = self.testapp.get("/hello", status=400) 126 | self.assertIn("Missing required query parameter: name", res.text) 127 | 128 | def test_name_too_short(self): 129 | """A name that is too short is picked up by openapi-core validation. 130 | 131 | We don't have to write (and test!) any validation code in our view! 132 | """ 133 | res = self.testapp.get("/hello?name=yo", status=400) 134 | self.assertIn("'yo' is too short", res.text) 135 | -------------------------------------------------------------------------------- /examples/singlefile/shell.nix: -------------------------------------------------------------------------------- 1 | ../../shell.nix -------------------------------------------------------------------------------- /examples/splitfile/README.md: -------------------------------------------------------------------------------- 1 | # An example RESTful API app showcasing how to split YAML spec into multiple files 2 | 3 | This folder showcases how to use the [pyramid_openapi3](https://github.com/Pylons/pyramid_openapi3) Pyramid add-on for building robust RESTful APIs defined with a multi-file YAML schema. 4 | 5 | ## How to run 6 | 7 | ```bash 8 | $ git clone https://github.com/Pylons/pyramid_openapi3.git 9 | $ cd pyramid_openapi3/examples/splitfile 10 | $ virtualenv -p python3.10 . 11 | $ source bin/activate 12 | $ pip install pyramid_openapi3 13 | $ python app.py 14 | ``` 15 | 16 | Then use the Swagger interface at http://localhost:6543/docs/ to discover the API. Use the `Try it out` button to run through a few request/response scenarios. 17 | 18 | For example: 19 | * Get all TODO items using the GET request. 20 | * Adding a new TODO item using the POST request. 21 | * Getting a 400 BadRequest response for an empty POST request 22 | * Getting a 400 BadRequest response for a POST request when `title` is too long (over 40 characters). 23 | 24 | All of these examples are covered with tests that you can run with `$ python -m unittest tests.py`. The tests need the Python webtest module installed. If your virtualenv is still activated you can install it with `pip install webtest`. 25 | 26 | 27 | ## Further read 28 | 29 | * A slightly simpler, multi-file (single spec file) example is available in the [`examples/todoapp`](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/todoapp) folder. 30 | 31 | * A simpler, single-file (spec in app) example is available in the [`examples/singlefile`](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/singlefile) folder. 32 | 33 | * A fully built-out app, with 100% test coverage, providing a [RealWorld.io](https://realworld.io) API is available at [niteoweb/pyramid-realworld-example-app](https://github.com/niteoweb/pyramid-realworld-example-app). It is a Heroku-deployable Pyramid app that provides an API for a Medium.com-like social app. You are encouraged to use it as a scaffold for your next project. 34 | 35 | * More information about the library providing the integration between OpenAPI specs and Pyramid, more advanced features and design defence, is available in the main [README](https://github.com/Pylons/pyramid_openapi3) file. 36 | 37 | * More validators for fields are listed in the [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md#properties) document. You can use Regex as well. 38 | 39 | * For an idea of a fully-fledged production OpenApi specification, check out [WooCart's OpenAPI docs](https://app.woocart.com/api/v1/). 40 | -------------------------------------------------------------------------------- /examples/splitfile/app.py: -------------------------------------------------------------------------------- 1 | """A Todo-app implementation using pyramid_openapi3 and YAML spec split into multiple files. 2 | 3 | See README.md at 4 | https://github.com/Pylons/pyramid_openapi3/tree/main/examples/splitfile 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from pyramid.config import Configurator 9 | from pyramid.request import Request 10 | from pyramid.router import Router 11 | from pyramid.view import view_config 12 | from wsgiref.simple_server import make_server 13 | 14 | import os 15 | import typing as t 16 | 17 | 18 | @dataclass 19 | class Item: 20 | """A single TODO item.""" 21 | 22 | title: str 23 | 24 | def __json__(self, request: Request) -> t.Dict[str, str]: 25 | """JSON-renderer for this object.""" 26 | return {"title": self.title} 27 | 28 | 29 | # fmt: off 30 | # Poor-man's in-memory database. Pre-populated with three TODO items. 31 | ITEMS = [ 32 | Item(title="Buy milk"), 33 | Item(title="Buy eggs"), 34 | Item(title="Make pankaces!"), 35 | ] 36 | # fmt: on 37 | 38 | 39 | @view_config(route_name="todo", renderer="json", request_method="GET", openapi=True) 40 | def get(request: Request) -> t.List[Item]: 41 | """Serve the list of TODO items for GET requests.""" 42 | return ITEMS 43 | 44 | 45 | @view_config(route_name="todo", renderer="json", request_method="POST", openapi=True) 46 | def post(request: Request) -> str: 47 | """Handle POST requests and create TODO items.""" 48 | item = Item(title=request.openapi_validated.body["title"]) 49 | ITEMS.append(item) 50 | return "Item added." 51 | 52 | 53 | def app() -> Router: 54 | """Prepare a Pyramid app.""" 55 | with Configurator() as config: 56 | config.include("pyramid_openapi3") 57 | config.add_static_view(name="spec", path="spec") 58 | config.pyramid_openapi3_spec_directory( 59 | os.path.join(os.path.dirname(__file__), "spec/openapi.yaml"), 60 | ) 61 | config.pyramid_openapi3_add_explorer() 62 | config.add_route("todo", "/") 63 | config.scan(".") 64 | 65 | return config.make_wsgi_app() 66 | 67 | 68 | if __name__ == "__main__": 69 | """If app.py is called directly, start up the app.""" 70 | print("Swagger UI available at http://0.0.0.0:6543/docs/") # noqa: T201 71 | server = make_server("0.0.0.0", 6543, app()) 72 | server.serve_forever() 73 | -------------------------------------------------------------------------------- /examples/splitfile/shell.nix: -------------------------------------------------------------------------------- 1 | ../../shell.nix -------------------------------------------------------------------------------- /examples/splitfile/spec/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.1.0" 2 | 3 | info: 4 | version: "1.0.0" 5 | title: A simple Todo app API 6 | 7 | paths: 8 | /: 9 | $ref: "paths.yaml#/todos" 10 | 11 | components: 12 | schemas: 13 | Item: 14 | $ref: "schemas.yaml#/Item" 15 | 16 | Error: 17 | $ref: "schemas.yaml#/Error" 18 | 19 | responses: 20 | ValidationError: 21 | $ref: "responses.yaml#/ValidationError" 22 | -------------------------------------------------------------------------------- /examples/splitfile/spec/paths.yaml: -------------------------------------------------------------------------------- 1 | todos: 2 | get: 3 | summary: List of my TODO items 4 | responses: 5 | '200': 6 | description: A list of my TODO items 7 | content: 8 | application/json: 9 | schema: 10 | type: array 11 | items: 12 | $ref: "schemas.yaml#/Item" 13 | 14 | post: 15 | summary: Create a TODO item 16 | operationId: todo.create 17 | requestBody: 18 | required: true 19 | description: Data for creating a new TODO item 20 | content: 21 | application/json: 22 | schema: 23 | $ref: schemas.yaml#/Item 24 | 25 | responses: 26 | '200': 27 | description: Success message. 28 | content: 29 | application/json: 30 | schema: 31 | type: string 32 | 33 | '400': 34 | $ref: responses.yaml#/ValidationError 35 | '500': 36 | $ref: responses.yaml#/ValidationError 37 | -------------------------------------------------------------------------------- /examples/splitfile/spec/responses.yaml: -------------------------------------------------------------------------------- 1 | ValidationError: 2 | description: OpenAPI request/response validation failed 3 | content: 4 | application/json: 5 | schema: 6 | type: array 7 | items: 8 | $ref: "schemas.yaml#/Error" 9 | -------------------------------------------------------------------------------- /examples/splitfile/spec/schemas.yaml: -------------------------------------------------------------------------------- 1 | Item: 2 | type: object 3 | required: 4 | - title 5 | properties: 6 | title: 7 | type: string 8 | maxLength: 40 9 | 10 | Error: 11 | type: object 12 | required: 13 | - message 14 | properties: 15 | field: 16 | type: string 17 | message: 18 | type: string 19 | exception: 20 | type: string 21 | -------------------------------------------------------------------------------- /examples/splitfile/tests.py: -------------------------------------------------------------------------------- 1 | """A couple of functional tests to showcase pyramid_openapi3 works with YAML spec split into multiple files.""" 2 | 3 | from unittest import mock 4 | from webtest import TestApp 5 | 6 | import app 7 | import unittest 8 | 9 | 10 | class TestHappyPath(unittest.TestCase): 11 | """A suite of tests that make "happy path" requests against the app.""" 12 | 13 | def setUp(self) -> None: 14 | """Start up the app so that tests can send requests to it.""" 15 | self.testapp = TestApp(app.app()) 16 | 17 | def test_list_todos(self) -> None: 18 | """Root returns a list of TODOs.""" 19 | res = self.testapp.get("/", status=200) 20 | self.assertEqual( 21 | res.json, 22 | [{"title": "Buy milk"}, {"title": "Buy eggs"}, {"title": "Make pankaces!"}], 23 | ) 24 | 25 | def test_add_todo(self) -> None: 26 | """POSTing to root saves a TODO.""" # noqa: D403 27 | res = self.testapp.post_json("/", {"title": "Add marmalade"}, status=200) 28 | self.assertEqual(res.json, "Item added.") 29 | 30 | # clean up after the test by removing the "Add marmalade" item 31 | app.ITEMS.pop() 32 | 33 | 34 | class TestBadRequests(TestHappyPath): 35 | """A suite of tests that showcase out-of-the-box handling of bad requests.""" 36 | 37 | def test_empty_POST(self) -> None: 38 | """Get a nice validation error when sending an empty POST request.""" 39 | res = self.testapp.post_json("/", {}, status=400) 40 | self.assertEqual( 41 | res.json, 42 | [ 43 | { 44 | "exception": "ValidationError", 45 | "field": "title", 46 | "message": "'title' is a required property", 47 | } 48 | ], 49 | ) 50 | 51 | def test_title_too_long(self) -> None: 52 | """Get a nice validation error when title is too long.""" 53 | res = self.testapp.post_json("/", {"title": "a" * 41}, status=400) 54 | self.assertEqual( 55 | res.json, 56 | [ 57 | { 58 | "exception": "ValidationError", 59 | "field": "title", 60 | "message": "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' is too long", 61 | } 62 | ], 63 | ) 64 | 65 | 66 | class TestBadResponses(TestHappyPath): 67 | """A suite of tests that showcase out-of-the-box handling of bad responses.""" 68 | 69 | def test_bad_items(self) -> None: 70 | """Test bad output from view. 71 | 72 | If our view returns JSON that does not match openapi.yaml schema, 73 | then we should render a 500 error. 74 | """ 75 | with mock.patch("app.ITEMS", ["foo", "bar"]): 76 | res = self.testapp.get("/", status=500) 77 | self.assertEqual( 78 | res.json, 79 | [ 80 | { 81 | "exception": "DataValidationError", 82 | "message": "Failed to cast value to object type: foo", 83 | }, 84 | ], 85 | ) 86 | -------------------------------------------------------------------------------- /examples/todoapp/README.md: -------------------------------------------------------------------------------- 1 | # An example RESTful API app showcasing the power of pyramid_openapi3 2 | 3 | This folder showcases how to use the [pyramid_openapi3](https://github.com/Pylons/pyramid_openapi3) Pyramid add-on for building robust RESTful APIs. With only a few lines of code you get automatic validation of requests and responses against an OpenAPI v3 schema, along with Swagger "try-it-out" documentation for your API. 4 | 5 | ## How to run 6 | 7 | ```bash 8 | $ git clone https://github.com/Pylons/pyramid_openapi3.git 9 | $ cd pyramid_openapi3/examples/todoapp 10 | $ virtualenv -p python3.10 . 11 | $ source bin/activate 12 | $ pip install pyramid_openapi3 13 | $ python app.py 14 | ``` 15 | 16 | Then use the Swagger interface at http://localhost:6543/docs/ to discover the API. Use the `Try it out` button to run through a few request/response scenarios. 17 | 18 | For example: 19 | * Get all TODO items using the GET request. 20 | * Adding a new TODO item using the POST request. 21 | * Getting a 400 BadRequest response for an empty POST request 22 | * Getting a 400 BadRequest response for a POST request when `title` is too long (over 40 characters). 23 | 24 | All of these examples are covered with tests that you can run with `$ python -m unittest tests.py`. The tests need the Python webtest module installed. If your virtualenv is still activated you can install it with `pip install webtest`. 25 | 26 | 27 | ## Further read 28 | 29 | * A simpler, single-file example is available in the [`examples/singlefile`](https://github.com/Pylons/pyramid_openapi3/tree/main/examples/singlefile) folder. 30 | 31 | * A fully built-out app, with 100% test coverage, providing a [RealWorld.io](https://realworld.io) API is available at [niteoweb/pyramid-realworld-example-app](https://github.com/niteoweb/pyramid-realworld-example-app). It is a Heroku-deployable Pyramid app that provides an API for a Medium.com-like social app. You are encouraged to use it as a scaffold for your next project. 32 | 33 | * More information about the library providing the integration between OpenAPI specs and Pyramid, more advanced features and design defence, is available in the main [README](https://github.com/Pylons/pyramid_openapi3) file. 34 | 35 | * More validators for fields are listed in the [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#properties) document. You can use Regex as well. 36 | 37 | * For an idea of a fully-fledged production OpenApi specification, check out [WooCart's OpenAPI docs](https://app.woocart.com/api/v1/). 38 | -------------------------------------------------------------------------------- /examples/todoapp/app.py: -------------------------------------------------------------------------------- 1 | """A Todo-app implementation using pyramid_openapi3. 2 | 3 | See README.md at 4 | https://github.com/Pylons/pyramid_openapi3/tree/main/examples/todoapp 5 | """ 6 | 7 | from dataclasses import dataclass 8 | from pyramid.config import Configurator 9 | from pyramid.exceptions import HTTPBadRequest 10 | from pyramid.request import Request 11 | from pyramid.router import Router 12 | from pyramid.view import view_config 13 | from wsgiref.simple_server import make_server 14 | 15 | import os 16 | import typing as t 17 | 18 | 19 | @dataclass 20 | class Item: 21 | """A single TODO item.""" 22 | 23 | title: str 24 | 25 | def __json__(self, request: Request) -> t.Dict[str, str]: 26 | """JSON-renderer for this object.""" 27 | return {"title": self.title} 28 | 29 | 30 | # fmt: off 31 | # Poor-man's in-memory database. Pre-populated with three TODO items. 32 | ITEMS = [ 33 | Item(title="Buy milk"), 34 | Item(title="Buy eggs"), 35 | Item(title="Make pankaces!"), 36 | ] 37 | # fmt: on 38 | 39 | 40 | @view_config(route_name="todos", renderer="json", request_method="GET", openapi=True) 41 | def get(request: Request) -> t.List[Item]: 42 | """Serve the list of TODO items for GET requests.""" 43 | parameters = request.openapi_validated.parameters 44 | limit = parameters.query.get("limit") 45 | if limit: 46 | return ITEMS[:limit] 47 | return ITEMS 48 | 49 | 50 | @view_config(route_name="todos", renderer="json", request_method="POST", openapi=True) 51 | def post(request: Request) -> str: 52 | """Handle POST requests and create TODO items.""" 53 | item = Item(title=request.openapi_validated.body["title"]) 54 | ITEMS.append(item) 55 | return "Item added." 56 | 57 | 58 | @view_config(route_name="todo", renderer="json", request_method="DELETE", openapi=True) 59 | def delete(request: Request) -> str: 60 | """Handle DELETE requests and delete a TODO item.""" 61 | parameters = request.openapi_validated.parameters 62 | try: 63 | del ITEMS[parameters.path["todo_id"]] 64 | except IndexError: 65 | raise HTTPBadRequest() 66 | return "Item Deleted." 67 | 68 | 69 | @view_config(route_name="todo", renderer="json", request_method="PUT", openapi=True) 70 | def put(request: Request) -> str: 71 | """Handle PUT requests and update a TODO item.""" 72 | parameters = request.openapi_validated.parameters 73 | try: 74 | item = ITEMS[parameters.path["todo_id"]] 75 | except IndexError: 76 | raise HTTPBadRequest() 77 | item.title = request.openapi_validated.body["title"] 78 | return "Item updated." 79 | 80 | 81 | def app() -> Router: 82 | """Prepare a Pyramid app.""" 83 | with Configurator() as config: 84 | config.include("pyramid_openapi3") 85 | config.pyramid_openapi3_spec( 86 | os.path.join(os.path.dirname(__file__), "openapi.yaml") 87 | ) 88 | config.pyramid_openapi3_add_explorer() 89 | config.add_route("todos", "/todos") 90 | config.add_route("todo", "/todos/{todo_id}") 91 | config.scan(".") 92 | 93 | return config.make_wsgi_app() 94 | 95 | 96 | if __name__ == "__main__": 97 | """If app.py is called directly, start up the app.""" 98 | print("Swagger UI available at http://0.0.0.0:6543/docs/") # noqa: T201 99 | server = make_server("0.0.0.0", 6543, app()) 100 | server.serve_forever() 101 | -------------------------------------------------------------------------------- /examples/todoapp/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.1.0" 2 | 3 | info: 4 | version: "1.0.0" 5 | title: A simple Todo app API 6 | 7 | paths: 8 | /todos: 9 | get: 10 | summary: List of my TODO items 11 | operationId: todo.list 12 | parameters: 13 | - $ref: "#/components/parameters/limit" 14 | responses: 15 | "200": 16 | description: A list of my TODO items 17 | content: 18 | application/json: 19 | schema: 20 | type: array 21 | items: 22 | $ref: "#/components/schemas/Item" 23 | "400": 24 | $ref: "#/components/responses/ValidationError" 25 | 26 | post: 27 | summary: Create a TODO item 28 | operationId: todo.create 29 | requestBody: 30 | $ref: "#/components/requestBodies/ItemBody" 31 | responses: 32 | "200": 33 | description: Success message. 34 | content: 35 | application/json: 36 | schema: 37 | type: string 38 | "400": 39 | $ref: "#/components/responses/ValidationError" 40 | 41 | /todos/{todo_id}: 42 | delete: 43 | summary: Delete a TODO item 44 | operationId: todo.delete 45 | parameters: 46 | - $ref: "#/components/parameters/todo_id" 47 | responses: 48 | "200": 49 | description: Success message. 50 | content: 51 | application/json: 52 | schema: 53 | type: string 54 | "400": 55 | $ref: "#/components/responses/ValidationError" 56 | 57 | put: 58 | summary: Update a TODO item 59 | operationId: todo.update 60 | parameters: 61 | - $ref: "#/components/parameters/todo_id" 62 | requestBody: 63 | $ref: "#/components/requestBodies/ItemBody" 64 | responses: 65 | "200": 66 | description: Success message. 67 | content: 68 | application/json: 69 | schema: 70 | type: string 71 | "400": 72 | $ref: "#/components/responses/ValidationError" 73 | 74 | components: 75 | parameters: 76 | limit: 77 | name: limit 78 | in: query 79 | description: The maximum number of TODO items to return 80 | schema: 81 | type: integer 82 | minimum: 1 83 | required: false 84 | 85 | todo_id: 86 | name: todo_id 87 | in: path 88 | description: The index of the TODO item to edit 89 | schema: 90 | type: integer 91 | minimum: 0 92 | required: true 93 | 94 | requestBodies: 95 | ItemBody: 96 | required: true 97 | description: Data for creating a new TODO item 98 | content: 99 | application/json: 100 | schema: 101 | $ref: "#/components/schemas/Item" 102 | 103 | schemas: 104 | Item: 105 | type: object 106 | required: 107 | - title 108 | properties: 109 | title: 110 | type: string 111 | maxLength: 40 112 | 113 | Error: 114 | type: object 115 | required: 116 | - message 117 | properties: 118 | field: 119 | type: string 120 | message: 121 | type: string 122 | exception: 123 | type: string 124 | 125 | responses: 126 | ValidationError: 127 | description: OpenAPI request/response validation failed 128 | content: 129 | application/json: 130 | schema: 131 | type: array 132 | items: 133 | $ref: "#/components/schemas/Error" 134 | -------------------------------------------------------------------------------- /examples/todoapp/shell.nix: -------------------------------------------------------------------------------- 1 | ../../shell.nix -------------------------------------------------------------------------------- /examples/todoapp/tests.py: -------------------------------------------------------------------------------- 1 | """A couple of functional tests to showcase pyramid_openapi3 features.""" 2 | 3 | from unittest import mock 4 | from webtest import TestApp 5 | 6 | import app 7 | import unittest 8 | 9 | 10 | class TestHappyPath(unittest.TestCase): 11 | """A suite of tests that make "happy path" requests against the app.""" 12 | 13 | def setUp(self) -> None: 14 | """Start up the app so that tests can send requests to it.""" 15 | self.testapp = TestApp(app.app()) 16 | 17 | def test_list_todos(self) -> None: 18 | """Root returns a list of TODOs.""" 19 | res = self.testapp.get("/todos", status=200) 20 | self.assertEqual( 21 | res.json, 22 | [{"title": "Buy milk"}, {"title": "Buy eggs"}, {"title": "Make pankaces!"}], 23 | ) 24 | 25 | def test_list_todos_with_limit(self) -> None: 26 | """Root returns a list of TODOs.""" 27 | res = self.testapp.get("/todos", {"limit": 2}, status=200) 28 | self.assertEqual( 29 | res.json, 30 | [{"title": "Buy milk"}, {"title": "Buy eggs"}], 31 | ) 32 | 33 | def test_add_todo(self) -> None: 34 | """POSTing to root saves a TODO.""" # noqa: D403 35 | res = self.testapp.post_json("/todos", {"title": "Add marmalade"}, status=200) 36 | self.assertEqual(res.json, "Item added.") 37 | 38 | # clean up after the test by removing the "Add marmalade" item 39 | app.ITEMS.pop() 40 | 41 | def test_delete_todo(self) -> None: 42 | """Root returns a list of TODOs.""" 43 | res = self.testapp.delete("/todos/2", status=200) 44 | self.assertEqual(res.json, "Item Deleted.") 45 | self.assertEqual( 46 | app.ITEMS, [app.Item(title="Buy milk"), app.Item(title="Buy eggs")] 47 | ) 48 | 49 | # clean up after the test by re-adding the deleted item 50 | app.ITEMS.append(app.Item(title="Make pankaces!")) 51 | 52 | def test_update_todo(self) -> None: 53 | """Root returns a list of TODOs.""" 54 | res = self.testapp.put_json("/todos/0", {"title": "Updated"}, status=200) 55 | self.assertEqual(res.json, "Item updated.") 56 | self.assertEqual(app.ITEMS[0], app.Item(title="Updated")) 57 | 58 | # clean up after the test by re-setting the updating item 59 | app.ITEMS[0].title = "Buy milk" 60 | 61 | 62 | class TestBadRequests(TestHappyPath): 63 | """A suite of tests that showcase out-of-the-box handling of bad requests.""" 64 | 65 | def test_empty_POST(self) -> None: 66 | """Get a nice validation error when sending an empty POST request.""" 67 | res = self.testapp.post_json("/todos", {}, status=400) 68 | self.assertEqual( 69 | res.json, 70 | [ 71 | { 72 | "exception": "ValidationError", 73 | "field": "title", 74 | "message": "'title' is a required property", 75 | } 76 | ], 77 | ) 78 | 79 | def test_title_too_long(self) -> None: 80 | """Get a nice validation error when title is too long.""" 81 | res = self.testapp.post_json("/todos", {"title": "a" * 41}, status=400) 82 | self.assertEqual( 83 | res.json, 84 | [ 85 | { 86 | "exception": "ValidationError", 87 | "field": "title", 88 | "message": "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' is too long", 89 | } 90 | ], 91 | ) 92 | 93 | 94 | class TestBadResponses(TestHappyPath): 95 | """A suite of tests that showcase out-of-the-box handling of bad responses.""" 96 | 97 | def test_bad_items(self) -> None: 98 | """Test bad output from view. 99 | 100 | If our view returns JSON that does not match openapi.yaml schema, 101 | then we should render a 500 error. 102 | """ 103 | with mock.patch("app.ITEMS", ["foo", "bar"]): 104 | res = self.testapp.get("/todos", status=500) 105 | self.assertEqual( 106 | res.json, 107 | [ 108 | { 109 | "exception": "DataValidationError", 110 | "message": "Failed to cast value to object type: foo", 111 | }, 112 | ], 113 | ) 114 | -------------------------------------------------------------------------------- /header.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/pyramid_openapi3/0833c801272f8df3497274de2f78b6f74b5a3ceb/header.jpg -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /py310/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyramid_openapi3" 3 | version = "0.20.1" 4 | description = "Pyramid addon for OpenAPI3 validation of requests and responses." 5 | readme = "README.md" 6 | authors = [ 7 | "Neyts Zupan", 8 | "Domen Kozar" 9 | ] 10 | license = "MIT" 11 | repository = "https://github.com/Pylons/pyramid_openapi3" 12 | keywords = ["pyramid", "openapi3", "openapi", "rest", "restful"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Developers", 16 | "Framework :: Pyramid", 17 | "Topic :: Internet :: WWW/HTTP", 18 | "Topic :: Software Development :: Libraries :: Python Modules", 19 | ] 20 | packages = [ 21 | { include = "pyramid_openapi3", from = "." }, 22 | ] 23 | exclude = ["pyramid_openapi3/tests/"] 24 | 25 | 26 | [tool.poetry.dependencies] 27 | python = "^3.10" 28 | 29 | openapi-core = "==0.19.1" 30 | pyramid = "==1.10.7" 31 | 32 | 33 | [tool.poetry.dev-dependencies] 34 | autoflake = "*" 35 | black = "*" 36 | codespell = "*" 37 | coverage = "*" 38 | docutils = "*" 39 | flake8 = "*" 40 | flake8-assertive = "*" 41 | flake8-blind-except = "*" 42 | flake8-bugbear = "*" 43 | flake8-builtins = "*" 44 | flake8-comprehensions = "*" 45 | flake8-debugger = "*" 46 | flake8-deprecated = "*" 47 | flake8-docstrings = "*" 48 | flake8-ensure-ascii = "*" 49 | flake8-plone-hasattr = "*" 50 | flake8-print = "*" 51 | flake8-self = "*" 52 | flake8-super-call = "*" 53 | flake8-tuple = "*" 54 | isort = "*" 55 | mccabe = "*" 56 | more-itertools = "*" 57 | mypy = "*" 58 | pdbpp = "*" 59 | pre-commit = "*" 60 | pre-commit-hooks = "*" 61 | pytest = "*" 62 | pytest-cov = "*" 63 | pytest-instafail = "*" 64 | pytest-randomly = "*" 65 | pytest-socket = "*" 66 | pyupgrade = "*" 67 | typecov = "*" 68 | types-pytest-lazy-fixture = "*" 69 | webtest = "*" 70 | yamllint = "*" 71 | 72 | 73 | [build-system] 74 | requires = ["poetry-core>=1.0.0"] 75 | build-backend = "poetry.core.masonry.api" 76 | 77 | 78 | [tool.autoflake] 79 | remove-all-unused-imports = true 80 | in-place = true 81 | recursive = true 82 | 83 | 84 | [tool.isort] 85 | atomic=true 86 | force_alphabetical_sort=true 87 | force_single_line=true 88 | line_length=88 89 | profile="black" 90 | 91 | 92 | [tool.mypy] 93 | follow_imports = "silent" 94 | check_untyped_defs = true 95 | disallow_untyped_calls = true 96 | disallow_untyped_defs = true 97 | disallow_incomplete_defs = true 98 | linecount_report = "./typecov" 99 | 100 | [[tool.mypy.overrides]] 101 | ignore_missing_imports = true 102 | module = [ 103 | "openapi_core.*", 104 | "openapi_spec_validator.*", 105 | "openapi_schema_validator.*", 106 | "hupper.*", 107 | "pyramid.*", 108 | "pytest", 109 | "webob.multidict.*", 110 | "webtest.*", 111 | "zope.interface.*", 112 | 113 | ] 114 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyramid_openapi3" 3 | version = "0.20.1" 4 | description = "Pyramid addon for OpenAPI3 validation of requests and responses." 5 | readme = "README.md" 6 | authors = [ 7 | "Neyts Zupan", 8 | "Domen Kozar" 9 | ] 10 | license = "MIT" 11 | repository = "https://github.com/Pylons/pyramid_openapi3" 12 | keywords = ["pyramid", "openapi3", "openapi", "rest", "restful"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Developers", 16 | "Framework :: Pyramid", 17 | "Topic :: Internet :: WWW/HTTP", 18 | "Topic :: Software Development :: Libraries :: Python Modules", 19 | ] 20 | packages = [ 21 | { include = "pyramid_openapi3", from = "." }, 22 | ] 23 | exclude = ["pyramid_openapi3/tests/"] 24 | 25 | 26 | [tool.poetry.dependencies] 27 | python = "^3.10" 28 | 29 | openapi-core = ">=0.19.1" 30 | pyramid = ">=1.10.7" 31 | 32 | 33 | [tool.poetry.dev-dependencies] 34 | autoflake = "*" 35 | black = "*" 36 | codespell = "*" 37 | coverage = "*" 38 | docutils = "*" 39 | flake8 = "*" 40 | flake8-assertive = "*" 41 | flake8-blind-except = "*" 42 | flake8-bugbear = "*" 43 | flake8-builtins = "*" 44 | flake8-comprehensions = "*" 45 | flake8-debugger = "*" 46 | flake8-deprecated = "*" 47 | flake8-docstrings = "*" 48 | flake8-ensure-ascii = "*" 49 | flake8-plone-hasattr = "*" 50 | flake8-print = "*" 51 | flake8-self = "*" 52 | flake8-super-call = "*" 53 | flake8-tuple = "*" 54 | isort = "*" 55 | mccabe = "*" 56 | more-itertools = "*" 57 | mypy = "*" 58 | pdbpp = "*" 59 | pre-commit = "*" 60 | pre-commit-hooks = "*" 61 | pytest = "*" 62 | pytest-cov = "*" 63 | pytest-instafail = "*" 64 | pytest-randomly = "*" 65 | pytest-socket = "*" 66 | pyupgrade = "*" 67 | typecov = "*" 68 | types-pytest-lazy-fixture = "*" 69 | webtest = "*" 70 | yamllint = "*" 71 | 72 | 73 | [build-system] 74 | requires = ["poetry-core>=1.0.0"] 75 | build-backend = "poetry.core.masonry.api" 76 | 77 | 78 | [tool.autoflake] 79 | remove-all-unused-imports = true 80 | in-place = true 81 | recursive = true 82 | 83 | 84 | [tool.isort] 85 | atomic=true 86 | force_alphabetical_sort=true 87 | force_single_line=true 88 | line_length=88 89 | profile="black" 90 | 91 | 92 | [tool.mypy] 93 | follow_imports = "silent" 94 | check_untyped_defs = true 95 | disallow_untyped_calls = true 96 | disallow_untyped_defs = true 97 | disallow_incomplete_defs = true 98 | linecount_report = "./typecov" 99 | 100 | [[tool.mypy.overrides]] 101 | ignore_missing_imports = true 102 | module = [ 103 | "openapi_core.*", 104 | "openapi_spec_validator.*", 105 | "openapi_schema_validator.*", 106 | "hupper.*", 107 | "pyramid.*", 108 | "pytest", 109 | "webob.multidict.*", 110 | "webtest.*", 111 | "zope.interface.*", 112 | 113 | ] 114 | -------------------------------------------------------------------------------- /pyramid_openapi3/__init__.py: -------------------------------------------------------------------------------- 1 | """Configure pyramid_openapi3 addon.""" 2 | 3 | from .exceptions import extract_errors 4 | from .exceptions import MissingEndpointsError 5 | from .exceptions import RequestValidationError 6 | from .exceptions import ResponseValidationError 7 | from .wrappers import PyramidOpenAPIRequest 8 | from jsonschema_path import SchemaPath 9 | from openapi_core.unmarshalling.request import V30RequestUnmarshaller 10 | from openapi_core.unmarshalling.request import V31RequestUnmarshaller 11 | from openapi_core.unmarshalling.response import V30ResponseUnmarshaller 12 | from openapi_core.unmarshalling.response import V31ResponseUnmarshaller 13 | from openapi_core.validation.request.exceptions import SecurityValidationError 14 | from openapi_spec_validator import validate 15 | from openapi_spec_validator.readers import read_from_filename 16 | from openapi_spec_validator.versions.shortcuts import get_spec_version 17 | from pathlib import Path 18 | from pyramid.config import Configurator 19 | from pyramid.config import PHASE0_CONFIG 20 | from pyramid.config import PHASE1_CONFIG 21 | from pyramid.config.views import ViewDeriverInfo 22 | from pyramid.events import ApplicationCreated 23 | from pyramid.exceptions import ConfigurationError 24 | from pyramid.httpexceptions import exception_response 25 | from pyramid.path import AssetResolver 26 | from pyramid.request import Request 27 | from pyramid.response import FileResponse 28 | from pyramid.response import Response 29 | from pyramid.security import NO_PERMISSION_REQUIRED 30 | from pyramid.settings import asbool 31 | from pyramid.tweens import EXCVIEW 32 | from string import Template 33 | from urllib.parse import urlparse 34 | 35 | import hupper 36 | import json 37 | import logging 38 | import typing as t 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | def includeme(config: Configurator) -> None: 44 | """Pyramid knob.""" 45 | config.add_request_method(openapi_validated, name="openapi_validated", reify=True) 46 | config.add_view_deriver(openapi_view) 47 | config.add_directive("pyramid_openapi3_add_formatter", add_formatter) 48 | config.add_directive("pyramid_openapi3_add_deserializer", add_deserializer) 49 | config.add_directive("pyramid_openapi3_add_unmarshaller", add_unmarshaller) 50 | config.add_directive("pyramid_openapi3_add_explorer", add_explorer_view) 51 | config.add_directive("pyramid_openapi3_spec", add_spec_view) 52 | config.add_directive("pyramid_openapi3_spec_directory", add_spec_view_directory) 53 | config.add_directive("pyramid_openapi3_register_routes", register_routes) 54 | config.add_tween("pyramid_openapi3.tween.response_tween_factory", over=EXCVIEW) 55 | config.add_subscriber(check_all_routes, ApplicationCreated) 56 | 57 | if not config.registry.settings.get( # pragma: no branch 58 | "pyramid_openapi3_extract_errors" 59 | ): 60 | config.registry.settings["pyramid_openapi3_extract_errors"] = extract_errors 61 | 62 | config.add_exception_view( 63 | view=openapi_validation_error, context=RequestValidationError 64 | ) 65 | 66 | config.add_exception_view( 67 | view=openapi_validation_error, context=ResponseValidationError 68 | ) 69 | 70 | 71 | def openapi_validated(request: Request) -> dict: 72 | """Get validated parameters.""" 73 | 74 | # We need this here in case someone calls request.openapi_validated on 75 | # a view marked with openapi=False 76 | if not request.environ.get("pyramid_openapi3.enabled"): 77 | raise AttributeError( 78 | "Cannot do openapi request validation on a view marked with openapi=False" 79 | ) 80 | 81 | gsettings = settings = request.registry.settings["pyramid_openapi3"] 82 | route_settings = gsettings.get("routes") 83 | if route_settings and request.matched_route.name in route_settings: 84 | settings = request.registry.settings[route_settings[request.matched_route.name]] 85 | 86 | if request.environ.get("pyramid_openapi3.validate_request"): 87 | openapi_request = PyramidOpenAPIRequest(request) 88 | validated = settings["request_validator"].unmarshal(openapi_request) 89 | return validated 90 | 91 | return {} # pragma: no cover 92 | 93 | 94 | Context = t.TypeVar("Context") 95 | View = t.Callable[[Context, Request], Response] 96 | 97 | 98 | def openapi_view(view: View, info: ViewDeriverInfo) -> View: 99 | """View deriver that takes care of request/response validation. 100 | 101 | If `openapi=True` is passed to `@view_config`, this decorator will: 102 | 103 | - validate request and submit results into request.openapi_validated 104 | - Only request is validated here. The response is validated inside a tween, 105 | so that other tweens can intercept the response, and only the final 106 | response is validated against the openapi spec. 107 | """ 108 | if info.options.get("openapi"): 109 | 110 | def wrapper_view(context: Context, request: Request) -> Response: 111 | # We need this to be able to raise AttributeError if view code 112 | # accesses request.openapi_validated on a view that is marked 113 | # with openapi=False 114 | request.environ["pyramid_openapi3.enabled"] = True 115 | 116 | # If view is marked with openapi=True (i.e. we are in this 117 | # function) and registry settings are not set to disable 118 | # validation, then do request/response validation 119 | request.environ["pyramid_openapi3.validate_request"] = asbool( 120 | request.registry.settings.get( 121 | "pyramid_openapi3.enable_request_validation", True 122 | ) 123 | ) 124 | request.environ["pyramid_openapi3.validate_response"] = asbool( 125 | request.registry.settings.get( 126 | "pyramid_openapi3.enable_response_validation", True 127 | ) 128 | ) 129 | 130 | # Request validation can happen already here, but response validation 131 | # needs to happen later in a tween 132 | if request.openapi_validated and request.openapi_validated.errors: 133 | raise RequestValidationError(errors=request.openapi_validated.errors) 134 | 135 | # Do the view 136 | return view(context, request) 137 | 138 | return wrapper_view 139 | return view 140 | 141 | 142 | openapi_view.options = ("openapi",) # type: ignore 143 | 144 | 145 | def add_explorer_view( 146 | config: Configurator, 147 | route: str = "/docs/", 148 | route_name: str = "pyramid_openapi3.explorer", 149 | template: str = "static/index.html", 150 | ui_version: str = "5.12.0", 151 | ui_config: t.Optional[dict[str, t.Any]] = None, 152 | oauth_config: t.Optional[dict[str, t.Any]] = None, 153 | oauth_redirect_route: t.Optional[str] = None, 154 | oauth_redirect_route_name: str = "pyramid_openapi3.explorer.oauth2-redirect", 155 | oauth_redirect_html: str = "static/oauth2-redirect.html", 156 | permission: str = NO_PERMISSION_REQUIRED, 157 | apiname: str = "pyramid_openapi3", 158 | ) -> None: 159 | """Serve Swagger UI at `route` url path. 160 | 161 | :param route: URL path where to serve 162 | :param route_name: Route name that's being added 163 | :param template: Dotted path to the html template that renders Swagger UI response 164 | :param ui_version: Swagger UI version string 165 | :param ui_config: 166 | A dictionary conforming to the SwaggerUI API. 167 | Any settings defined here will override those defined by default. 168 | :param oauth_config: 169 | If defined, then SwaggerUI.initOAuth will be invoked with the supplied config. 170 | :param oauth_redirect_route: 171 | URL path where the redirect will be served. By default the path is constructed 172 | by appending a ``/oauth2-redirect`` path component to the ``route`` parameter. 173 | :param oauth_redirect_route_name: 174 | Route name for the redirect route. 175 | :param oauth_redirect_html: 176 | Dotted path to the html that renders the oauth2-redirect HTML. 177 | :param permission: Permission for the explorer view 178 | """ 179 | 180 | if oauth_redirect_route is None: 181 | oauth_redirect_route = route.rstrip("/") + "/oauth2-redirect" 182 | 183 | def register() -> None: 184 | asset_resolver = AssetResolver() 185 | resolved_template = asset_resolver.resolve(template) 186 | redirect_html = asset_resolver.resolve(oauth_redirect_html) 187 | 188 | def explorer_view(request: Request) -> Response: 189 | settings = config.registry.settings 190 | if settings.get(apiname) is None: 191 | raise ConfigurationError( 192 | "You need to call config.pyramid_openapi3_spec for the explorer " 193 | "to work." 194 | ) 195 | with open(resolved_template.abspath()) as f: 196 | template = Template(f.read()) 197 | merged_ui_config = { 198 | "url": request.route_path(settings[apiname]["spec_route_name"]), 199 | "dom_id": "#swagger-ui", 200 | "deepLinking": True, 201 | "validatorUrl": None, 202 | "layout": "StandaloneLayout", 203 | "oauth2RedirectUrl": request.route_url(oauth_redirect_route_name), 204 | } 205 | if ui_config: 206 | merged_ui_config.update(ui_config) 207 | html = template.safe_substitute( 208 | ui_version=ui_version, 209 | ui_config=json.dumps(merged_ui_config), 210 | oauth_config=json.dumps(oauth_config), 211 | ) 212 | return Response(html) 213 | 214 | config.add_route(route_name, route) 215 | config.add_view( 216 | route_name=route_name, permission=permission, view=explorer_view 217 | ) 218 | 219 | def redirect_view(request: Request) -> FileResponse: 220 | return FileResponse(redirect_html.abspath()) 221 | 222 | config.add_route(oauth_redirect_route_name, oauth_redirect_route) 223 | config.add_view( 224 | route_name=oauth_redirect_route_name, 225 | permission=permission, 226 | view=redirect_view, 227 | ) 228 | 229 | config.action((f"{apiname}_add_explorer",), register, order=PHASE0_CONFIG) 230 | 231 | 232 | def add_formatter(config: Configurator, name: str, func: t.Callable) -> None: 233 | """Add support for configuring formatters.""" 234 | config.registry.settings.setdefault("pyramid_openapi3_formatters", {}) 235 | reg = config.registry.settings["pyramid_openapi3_formatters"] 236 | reg[name] = func 237 | 238 | 239 | def add_deserializer(config: Configurator, name: str, func: t.Callable) -> None: 240 | """Add support for configuring deserializers.""" 241 | config.registry.settings.setdefault("pyramid_openapi3_deserializers", {}) 242 | reg = config.registry.settings["pyramid_openapi3_deserializers"] 243 | reg[name] = func 244 | 245 | 246 | def add_unmarshaller(config: Configurator, name: str, func: t.Callable) -> None: 247 | """Add support for configuring unmarshallers.""" 248 | config.registry.settings.setdefault("pyramid_openapi3_unmarshallers", {}) 249 | reg = config.registry.settings["pyramid_openapi3_unmarshallers"] 250 | reg[name] = func 251 | 252 | 253 | def add_spec_view( 254 | config: Configurator, 255 | filepath: str, 256 | route: str = "/openapi.yaml", 257 | route_name: str = "pyramid_openapi3.spec", 258 | permission: str = NO_PERMISSION_REQUIRED, 259 | apiname: str = "pyramid_openapi3", 260 | ) -> None: 261 | """Serve and register OpenApi 3.0 specification file. 262 | 263 | :param filepath: absolute/relative path to the specification file 264 | :param route: URL path where to serve specification file 265 | :param route_name: Route name under which specification file will be served 266 | :param permission: Permission for the spec view 267 | """ 268 | 269 | def register() -> None: 270 | settings = config.registry.settings.get(apiname) 271 | if settings and settings.get("spec") is not None: 272 | raise ConfigurationError( 273 | "Spec has already been configured. You may only call " 274 | "pyramid_openapi3_spec or pyramid_openapi3_spec_directory once" 275 | ) 276 | 277 | if hupper.is_active(): # pragma: no cover 278 | hupper.get_reloader().watch_files([filepath]) 279 | spec_dict, _ = read_from_filename(filepath) 280 | 281 | validate(spec_dict) 282 | spec = SchemaPath.from_dict(spec_dict) 283 | 284 | def spec_view(request: Request) -> FileResponse: 285 | return FileResponse(filepath, request=request, content_type="text/yaml") 286 | 287 | config.add_route(route_name, route) 288 | config.add_view(route_name=route_name, permission=permission, view=spec_view) 289 | 290 | config.registry.settings[apiname] = _create_api_settings( 291 | config, filepath, route_name, spec 292 | ) 293 | config.registry.settings.setdefault("pyramid_openapi3_apinames", []).append( 294 | apiname 295 | ) 296 | 297 | config.action((f"{apiname}_spec",), register, order=PHASE0_CONFIG) 298 | 299 | 300 | def add_spec_view_directory( 301 | config: Configurator, 302 | filepath: str, 303 | route: str = "/spec", 304 | route_name: str = "pyramid_openapi3.spec", 305 | permission: str = NO_PERMISSION_REQUIRED, 306 | apiname: str = "pyramid_openapi3", 307 | ) -> None: 308 | """Serve and register OpenApi 3.0 specification directory. 309 | 310 | :param filepath: absolute/relative path to the root specification file 311 | :param route: URL path where to serve specification file 312 | :param route_name: Route name under which specification file will be served 313 | """ 314 | 315 | def register() -> None: 316 | settings = config.registry.settings.get(apiname) 317 | if settings and settings.get("spec") is not None: 318 | raise ConfigurationError( 319 | "Spec has already been configured. You may only call " 320 | "pyramid_openapi3_spec or pyramid_openapi3_spec_directory once" 321 | ) 322 | if route.endswith((".yaml", ".yml", ".json")): 323 | raise ConfigurationError( 324 | "Having route be a filename is not allowed when using a spec directory" 325 | ) 326 | 327 | path = Path(filepath).resolve() 328 | if hupper.is_active(): # pragma: no cover 329 | hupper.get_reloader().watch_files(list(path.parent.iterdir())) 330 | 331 | spec_dict, _ = read_from_filename(str(path)) 332 | spec_url = path.as_uri() 333 | validate(spec_dict, base_uri=spec_url) 334 | spec = SchemaPath.from_dict(spec_dict, base_uri=spec_url) 335 | 336 | config.add_static_view(route, str(path.parent), permission=permission) 337 | config.add_route(route_name, f"{route}/{path.name}") 338 | 339 | config.registry.settings[apiname] = _create_api_settings( 340 | config, filepath, route_name, spec 341 | ) 342 | config.registry.settings.setdefault("pyramid_openapi3_apinames", []).append( 343 | apiname 344 | ) 345 | 346 | config.action((f"{apiname}_spec",), register, order=PHASE0_CONFIG) 347 | 348 | 349 | def _create_api_settings( 350 | config: Configurator, filepath: str, route_name: str, spec: SchemaPath 351 | ) -> t.Dict: 352 | custom_formatters = config.registry.settings.get("pyramid_openapi3_formatters") 353 | custom_deserializers = config.registry.settings.get( 354 | "pyramid_openapi3_deserializers" 355 | ) 356 | custom_unmarshallers = config.registry.settings.get( 357 | "pyramid_openapi3_unmarshallers" 358 | ) 359 | 360 | # switch unmarshaller based on spec version 361 | spec_version = get_spec_version(spec.contents()) 362 | request_unmarshallers = { 363 | "OpenAPIV3.0": V30RequestUnmarshaller, 364 | "OpenAPIV3.1": V31RequestUnmarshaller, 365 | } 366 | response_unmarshallers = { 367 | "OpenAPIV3.0": V30ResponseUnmarshaller, 368 | "OpenAPIV3.1": V31ResponseUnmarshaller, 369 | } 370 | request_unmarshaller = request_unmarshallers[str(spec_version)] 371 | response_unmarshaller = response_unmarshallers[str(spec_version)] 372 | 373 | return { 374 | "filepath": filepath, 375 | "spec_route_name": route_name, 376 | "spec": spec, 377 | "request_validator": request_unmarshaller( 378 | spec, 379 | extra_format_validators=custom_formatters, 380 | extra_media_type_deserializers=custom_deserializers, 381 | extra_format_unmarshallers=custom_unmarshallers, 382 | ), 383 | "response_validator": response_unmarshaller( 384 | spec, 385 | extra_format_validators=custom_formatters, 386 | extra_media_type_deserializers=custom_deserializers, 387 | extra_format_unmarshallers=custom_unmarshallers, 388 | ), 389 | } 390 | 391 | 392 | def register_routes( 393 | config: Configurator, 394 | route_name_ext: str = "x-pyramid-route-name", 395 | root_factory_ext: str = "x-pyramid-root-factory", 396 | apiname: str = "pyramid_openapi3", 397 | route_prefix: t.Optional[str] = None, 398 | ) -> None: 399 | """Register routes to app from OpenApi 3.0 specification. 400 | 401 | :param route_name_ext: Extension's key for using a ``route_name`` argument 402 | :param root_factory_ext: Extension's key for using a ``factory`` argument 403 | """ 404 | 405 | def action() -> None: 406 | spec = config.registry.settings[apiname]["spec"] 407 | for pattern, path_item in spec["paths"].items(): 408 | route_name = path_item.get(route_name_ext) 409 | if route_name: 410 | root_factory = path_item.get(root_factory_ext) 411 | config.add_route( 412 | route_name, 413 | pattern=( 414 | route_prefix + pattern if route_prefix is not None else pattern 415 | ), 416 | factory=root_factory or None, 417 | ) 418 | 419 | config.action(("pyramid_openapi3_register_routes",), action, order=PHASE1_CONFIG) 420 | 421 | 422 | def openapi_validation_error( 423 | context: t.Union[RequestValidationError, ResponseValidationError], request: Request 424 | ) -> Response: 425 | """Render any validation errors as JSON.""" 426 | if isinstance(context, RequestValidationError): 427 | logger.warning(context) 428 | if isinstance(context, ResponseValidationError): 429 | logger.error(context) 430 | 431 | extract_errors = request.registry.settings["pyramid_openapi3_extract_errors"] 432 | errors = list(extract_errors(request, context.errors)) 433 | 434 | # If validation failed for request, it is user's fault (-> 400), but if 435 | # validation failed for response, it is our fault (-> 500) 436 | if isinstance(context, RequestValidationError): 437 | status_code = 400 438 | for error in context.errors: 439 | if isinstance(error, SecurityValidationError): 440 | status_code = 401 441 | 442 | if isinstance(context, ResponseValidationError): 443 | status_code = 500 444 | 445 | return exception_response(status_code, json_body=errors) 446 | 447 | 448 | def check_all_routes(event: ApplicationCreated) -> None: 449 | """Assert all endpoints in the spec are registered as routes. 450 | 451 | Listen for ApplicationCreated event and assert all endpoints defined in 452 | the API spec have been registered as Pyramid routes. 453 | """ 454 | 455 | def remove_prefixes(path: str) -> str: 456 | path = f"/{path}" if not path.startswith("/") else path 457 | for prefix in prefixes: 458 | if path.startswith(prefix): 459 | prefix_length = len(prefix) 460 | return path[prefix_length:] 461 | return path 462 | 463 | app = event.app 464 | settings = app.registry.settings 465 | apinames = settings.get("pyramid_openapi3_apinames") 466 | if not apinames: 467 | # pyramid_openapi3 not configured? 468 | logger.warning( 469 | "pyramid_openapi3 settings not found. " 470 | "Did you forget to call config.pyramid_openapi3_spec?" 471 | ) 472 | return 473 | 474 | for name in apinames: # pragma: no branch 475 | openapi_settings = settings[name] 476 | 477 | if not settings.get("pyramid_openapi3.enable_endpoint_validation", True): 478 | logger.info("Endpoint validation against specification is disabled") 479 | return 480 | 481 | prefixes = _get_server_prefixes(openapi_settings["spec"]) 482 | 483 | paths = list(openapi_settings["spec"]["paths"].keys()) 484 | routes = [ 485 | remove_prefixes(route.path) for route in app.routes_mapper.routes.values() 486 | ] 487 | 488 | missing = [r for r in paths if r not in routes] 489 | if missing: 490 | raise MissingEndpointsError(missing) 491 | 492 | settings.setdefault("pyramid_openapi3", {}) 493 | settings["pyramid_openapi3"].setdefault("routes", {}) 494 | 495 | # It is possible to have multiple `add_route` for a single path 496 | # (due to request_method predicates). So loop through each route 497 | # to create a lookup of route_name -> api_name 498 | for route_name, route in app.routes_mapper.routes.items(): 499 | if remove_prefixes(route.path) in paths: 500 | settings["pyramid_openapi3"]["routes"][route_name] = name 501 | 502 | 503 | def _get_server_prefixes(spec: SchemaPath) -> t.List[str]: 504 | """Build a set of possible route prefixes from the api spec. 505 | 506 | Api routes may optionally be prefixed using servers (e.g: `/api/v1`). 507 | See: https://swagger.io/docs/specification/api-host-and-base-path/ 508 | """ 509 | servers = spec.get("servers") 510 | if not servers: 511 | return [] 512 | 513 | prefixes = [] 514 | for server in servers: 515 | path = urlparse(server["url"]).path 516 | path = f"/{path}" if not path.startswith("/") else path 517 | if path != "/": 518 | prefixes.append(path) 519 | return prefixes 520 | -------------------------------------------------------------------------------- /pyramid_openapi3/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions used in pyramid_openapi3.""" 2 | 3 | from dataclasses import dataclass 4 | from openapi_core.exceptions import OpenAPIError 5 | from openapi_core.unmarshalling.schemas.exceptions import UnmarshallerError 6 | from pyramid.httpexceptions import HTTPBadRequest 7 | from pyramid.httpexceptions import HTTPInternalServerError 8 | from pyramid.request import Request 9 | from pyramid.response import Response 10 | 11 | import typing as t 12 | 13 | 14 | class RequestValidationError(HTTPBadRequest): 15 | """Error raised when Request validation fails.""" 16 | 17 | explanation = "Request validation failed." 18 | 19 | def __init__( 20 | self, *args: t.Any, errors: t.List[Exception], **kwargs: t.Any 21 | ) -> None: 22 | super().__init__(*args, **kwargs) 23 | self.errors = errors 24 | self.detail = self.message = "\n".join(str(e) for e in errors) 25 | 26 | def __str__(self) -> str: 27 | """Return str(self.detail) or self.explanation.""" 28 | return str(self.detail) if self.detail else self.explanation 29 | 30 | 31 | class ResponseValidationError(HTTPInternalServerError): 32 | """Error raised when Response validation fails.""" 33 | 34 | explanation = "Response validation failed." 35 | 36 | def __init__( 37 | self, 38 | *args: t.Any, 39 | response: Response, 40 | errors: t.List[Exception], 41 | **kwargs: t.Any, 42 | ) -> None: 43 | super().__init__(*args, **kwargs) 44 | self.response = response 45 | self.errors = errors 46 | self.detail = self.message = "\n".join(str(e) for e in errors) 47 | 48 | def __str__(self) -> str: 49 | """Return str(self.detail) or self.explanation.""" 50 | return str(self.detail) if self.detail else self.explanation 51 | 52 | 53 | @dataclass 54 | class InvalidCustomFormatterValue(UnmarshallerError): 55 | """Value failed to format with a custom formatter.""" 56 | 57 | field: str 58 | value: str 59 | type: str # noqa: A003 # we use `type` as a dataclass field name 60 | original_exception: Exception 61 | 62 | def __str__(self) -> str: 63 | """Provide more control over error message.""" 64 | return str(self.original_exception) 65 | 66 | 67 | class ImproperAPISpecificationWarning(UserWarning): 68 | """A warning that an end-user's API specification has a problem.""" 69 | 70 | 71 | def extract_errors( 72 | request: Request, errors: t.List[OpenAPIError], parent_field: t.Optional[str] = None 73 | ) -> t.Iterator[t.Dict[str, str]]: 74 | """Extract errors for JSON response. 75 | 76 | You can tell pyramid_openapi3 to use your own version of this 77 | function by providing a dotted-name to your function in 78 | `request.registry.settings["pyramid_openapi3_extract_errors"]`. 79 | 80 | This function expects the below definition in openapi.yaml 81 | file. If your openapi.yaml is different, you have to 82 | provide your own extract_errors() function. 83 | 84 | ``` 85 | components: 86 | 87 | schemas: 88 | 89 | Error: 90 | type: object 91 | required: 92 | - exception 93 | - message 94 | properties: 95 | field: 96 | type: string 97 | message: 98 | type: string 99 | exception: 100 | type: string 101 | 102 | responses: 103 | 104 | ValidationError: 105 | description: OpenAPI request/response validation failed 106 | content: 107 | application/json: 108 | schema: 109 | type: array 110 | items: 111 | $ref: "#/components/schemas/Error" 112 | ``` 113 | """ 114 | for err in errors: 115 | schema_errors = getattr(err.__cause__, "schema_errors", None) 116 | if schema_errors is not None: 117 | yield from extract_errors( 118 | request, schema_errors, getattr(err, "name", None) 119 | ) 120 | continue 121 | 122 | output = {"exception": err.__class__.__name__} 123 | 124 | message = getattr(err, "message", None) 125 | if getattr(err, "__cause__", None): 126 | message = str(err.__cause__) 127 | if message is None: 128 | message = str(err) 129 | 130 | output.update({"message": message}) 131 | 132 | field = getattr(err, "field", parent_field) 133 | if field is None: 134 | field = getattr(err, "name", None) 135 | if field is None and getattr(err, "validator", None) == "required": 136 | field = "/".join(getattr(err, "validator_value", [])) 137 | if field is None: 138 | path = getattr(err, "path", None) 139 | if path and path[0] and isinstance(path[0], str): 140 | field = "/".join([str(part) for part in path]) 141 | 142 | if field: 143 | output.update({"field": field}) 144 | 145 | yield output 146 | 147 | 148 | class MissingEndpointsError(Exception): 149 | """Error raised when endpoints are not found.""" 150 | 151 | missing: list 152 | 153 | def __init__(self, missing: t.List[str]) -> None: 154 | self.missing = missing 155 | message = f"Unable to find routes for endpoints: {', '.join(missing)}" 156 | super().__init__(message) 157 | -------------------------------------------------------------------------------- /pyramid_openapi3/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/pyramid_openapi3/0833c801272f8df3497274de2f78b6f74b5a3ceb/pyramid_openapi3/py.typed -------------------------------------------------------------------------------- /pyramid_openapi3/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 | 68 | 69 | 70 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /pyramid_openapi3/static/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger UI: OAuth2 Redirect 5 | 6 | 7 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # noqa 2 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_add_deserializer.py: -------------------------------------------------------------------------------- 1 | """Tests for registering custom deserializers.""" 2 | 3 | from pyramid.testing import DummyRequest 4 | from pyramid.testing import testConfig 5 | 6 | 7 | def test_add_deserializer() -> None: 8 | """Test registration of a custom deserializer.""" 9 | 10 | with testConfig() as config: 11 | request = DummyRequest() 12 | 13 | config.include("pyramid_openapi3") 14 | config.pyramid_openapi3_add_deserializer("deserializer", lambda x: x) 15 | 16 | deserializer = request.registry.settings["pyramid_openapi3_deserializers"].get( 17 | "deserializer", None 18 | ) 19 | assert deserializer("foo") == "foo" 20 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_add_formatter.py: -------------------------------------------------------------------------------- 1 | """Tests for registering custom formatters.""" 2 | 3 | from pyramid.testing import DummyRequest 4 | from pyramid.testing import testConfig 5 | 6 | 7 | def test_add_formatter() -> None: 8 | """Test registration of a custom formatter.""" 9 | 10 | with testConfig() as config: 11 | request = DummyRequest() 12 | 13 | config.include("pyramid_openapi3") 14 | config.pyramid_openapi3_add_formatter("foormatter", lambda x: x) 15 | 16 | formatter = request.registry.settings["pyramid_openapi3_formatters"].get( 17 | "foormatter", None 18 | ) 19 | assert formatter("foo") == "foo" 20 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_add_unmarshaller.py: -------------------------------------------------------------------------------- 1 | """Tests for registering custom unmarshallers.""" 2 | 3 | from pyramid.testing import DummyRequest 4 | from pyramid.testing import testConfig 5 | 6 | 7 | def test_add_unmarshaller() -> None: 8 | """Test registration of a custom unmarshaller.""" 9 | 10 | with testConfig() as config: 11 | request = DummyRequest() 12 | 13 | config.include("pyramid_openapi3") 14 | config.pyramid_openapi3_add_unmarshaller("unmarshaller", lambda x: x) 15 | 16 | unmarshaller = request.registry.settings["pyramid_openapi3_unmarshallers"].get( 17 | "unmarshaller", None 18 | ) 19 | assert unmarshaller("foo") == "foo" 20 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_app_construction.py: -------------------------------------------------------------------------------- 1 | """Tests for app creation when using pyramid_openapi3.""" 2 | 3 | from _pytest.fixtures import SubRequest 4 | from _pytest.logging import LogCaptureFixture 5 | from pyramid.config import Configurator 6 | from pyramid.request import Request 7 | from pyramid.testing import testConfig 8 | from pyramid_openapi3 import MissingEndpointsError 9 | 10 | import logging 11 | import os 12 | import pytest 13 | import tempfile 14 | import typing as t 15 | 16 | DOCUMENT = b""" 17 | openapi: "3.1.0" 18 | info: 19 | version: "1.0.0" 20 | title: Foo API 21 | servers: 22 | - url: /api/v1 23 | paths: 24 | /foo: 25 | get: 26 | responses: 27 | 200: 28 | description: A foo 29 | post: 30 | responses: 31 | 200: 32 | description: A POST foo 33 | /bar: 34 | get: 35 | responses: 36 | 200: 37 | description: A bar 38 | """ 39 | 40 | SPLIT_DOCUMENT = b""" 41 | openapi: "3.1.0" 42 | info: 43 | version: "1.0.0" 44 | title: Foo API 45 | servers: 46 | - url: /api/v1 47 | paths: 48 | /foo: 49 | $ref: "paths.yaml#/foo" 50 | /bar: 51 | $ref: "paths.yaml#/bar" 52 | """ 53 | 54 | SPLIT_DOCUMENT_PATHS = b""" 55 | foo: 56 | get: 57 | responses: 58 | 200: 59 | description: A foo 60 | post: 61 | responses: 62 | 200: 63 | description: A POST foo 64 | bar: 65 | get: 66 | responses: 67 | 200: 68 | description: A bar 69 | """ 70 | 71 | # A test for when someone defines a `server.url` to just be `/` 72 | ROOT_SERVER_DOCUMENT = b""" 73 | openapi: "3.1.0" 74 | info: 75 | version: "1.0.0" 76 | title: Foo API 77 | servers: 78 | - url: / 79 | paths: 80 | /foo: 81 | get: 82 | responses: 83 | 200: 84 | description: A foo 85 | post: 86 | responses: 87 | 200: 88 | description: A POST foo 89 | /bar: 90 | get: 91 | responses: 92 | 200: 93 | description: A bar 94 | """ 95 | 96 | 97 | def foo_view(request: Request) -> str: 98 | """Return a dummy string.""" 99 | return "Foo" # pragma: no cover 100 | 101 | 102 | def bar_view(request: Request) -> str: 103 | """Return a dummy string.""" 104 | return "Bar" # pragma: no cover 105 | 106 | 107 | @pytest.fixture 108 | def document() -> t.Generator[t.IO, None, None]: 109 | """Load the DOCUMENT into a temp file.""" 110 | with tempfile.NamedTemporaryFile() as document: 111 | document.write(DOCUMENT) 112 | document.seek(0) 113 | 114 | yield document 115 | 116 | 117 | @pytest.fixture 118 | def directory_document() -> t.Generator[str, None, None]: 119 | """Load the DOCUMENT into a temp file.""" 120 | with tempfile.TemporaryDirectory() as directory: 121 | spec_name = os.path.join(directory, "openapi.yaml") 122 | spec_paths_name = os.path.join(directory, "paths.yaml") 123 | with open(spec_name, "wb") as f: 124 | f.write(SPLIT_DOCUMENT) 125 | with open(spec_paths_name, "wb") as f: 126 | f.write(SPLIT_DOCUMENT_PATHS) 127 | 128 | yield spec_name 129 | 130 | 131 | @pytest.fixture 132 | def root_server_document() -> t.Generator[t.IO, None, None]: 133 | """Load the ROOT_SERVER_DOCUMENT into a temp file.""" 134 | with tempfile.NamedTemporaryFile() as document: 135 | document.write(ROOT_SERVER_DOCUMENT) 136 | document.seek(0) 137 | 138 | yield document 139 | 140 | 141 | @pytest.fixture 142 | def simple_config() -> Configurator: 143 | """Config fixture.""" 144 | with testConfig() as config: 145 | config.include("pyramid_openapi3") 146 | 147 | yield config 148 | 149 | 150 | @pytest.fixture 151 | def simple_app_config( 152 | simple_config: Configurator, document: t.IO 153 | ) -> t.Generator[Configurator, None, None]: 154 | """Incremented fixture that loads the DOCUMENT above into the config.""" 155 | simple_config.pyramid_openapi3_spec( 156 | document.name, route="/foo.yaml", route_name="foo_api_spec" 157 | ) 158 | yield simple_config 159 | 160 | 161 | @pytest.fixture 162 | def split_file_app_config( 163 | simple_config: Configurator, directory_document: str 164 | ) -> t.Generator[Configurator, None, None]: 165 | """Incremented fixture that loads the SPLIT_DOCUMENT above into the config.""" 166 | simple_config.pyramid_openapi3_spec_directory( 167 | directory_document, route="/foo", route_name="foo_api_spec" 168 | ) 169 | yield simple_config 170 | 171 | 172 | @pytest.fixture 173 | def root_server_app_config( 174 | simple_config: Configurator, root_server_document: t.IO 175 | ) -> t.Generator[Configurator, None, None]: 176 | """Incremented fixture that loads the ROOT_SERVER_DOCUMENT above into the config.""" 177 | simple_config.pyramid_openapi3_spec( 178 | root_server_document.name, route="/foo.yaml", route_name="foo_api_spec" 179 | ) 180 | yield simple_config 181 | 182 | 183 | app_config = pytest.mark.parametrize( 184 | "app_config", 185 | [ 186 | "simple_app_config", 187 | "split_file_app_config", 188 | ], 189 | ) 190 | 191 | 192 | @app_config 193 | def test_all_routes(app_config: Configurator, request: SubRequest) -> None: 194 | """Test case showing that an app can be created with all routes defined.""" 195 | app_config = request.getfixturevalue(app_config) 196 | app_config.add_route(name="foo", pattern="/foo") 197 | app_config.add_route(name="bar", pattern="/bar") 198 | app_config.add_view( 199 | foo_view, route_name="foo", renderer="string", request_method="OPTIONS" 200 | ) 201 | app_config.add_view( 202 | bar_view, route_name="bar", renderer="string", request_method="GET" 203 | ) 204 | 205 | app_config.make_wsgi_app() 206 | 207 | 208 | @app_config 209 | def test_prefixed_routes(app_config: Configurator, request: SubRequest) -> None: 210 | """Test case for prefixed routes.""" 211 | app_config = request.getfixturevalue(app_config) 212 | app_config.add_route(name="foo", pattern="/api/v1/foo") 213 | app_config.add_route(name="bar", pattern="/api/v1/bar") 214 | app_config.add_view( 215 | foo_view, route_name="foo", renderer="string", request_method="OPTIONS" 216 | ) 217 | app_config.add_view( 218 | bar_view, route_name="bar", renderer="string", request_method="GET" 219 | ) 220 | 221 | app_config.make_wsgi_app() 222 | 223 | 224 | @app_config 225 | def test_pyramid_prefixed_context_routes( 226 | app_config: Configurator, request: SubRequest 227 | ) -> None: 228 | """Test case for prefixed routes using pyramid route_prefix_context.""" 229 | app_config = request.getfixturevalue(app_config) 230 | with app_config.route_prefix_context("/api/v1"): 231 | app_config.add_route(name="foo", pattern="/foo") 232 | app_config.add_route(name="bar", pattern="/bar") 233 | app_config.add_view( 234 | foo_view, route_name="foo", renderer="string", request_method="OPTIONS" 235 | ) 236 | app_config.add_view( 237 | bar_view, route_name="bar", renderer="string", request_method="GET" 238 | ) 239 | 240 | app_config.make_wsgi_app() 241 | 242 | 243 | @app_config 244 | def test_missing_routes(app_config: Configurator, request: SubRequest) -> None: 245 | """Test case showing app creation fails, when defined routes are missing.""" 246 | app_config = request.getfixturevalue(app_config) 247 | with pytest.raises(MissingEndpointsError) as ex: 248 | app_config.make_wsgi_app() 249 | 250 | assert str(ex.value) == "Unable to find routes for endpoints: /foo, /bar" 251 | 252 | 253 | @app_config 254 | def test_disable_endpoint_validation( 255 | app_config: Configurator, caplog: LogCaptureFixture, request: SubRequest 256 | ) -> None: 257 | """Test case showing app creation whilst disabling endpoint validation.""" 258 | caplog.set_level(logging.INFO) 259 | app_config = request.getfixturevalue(app_config) 260 | app_config.registry.settings["pyramid_openapi3.enable_endpoint_validation"] = False 261 | app_config.add_route(name="foo", pattern="/foo") 262 | app_config.add_view( 263 | foo_view, route_name="foo", renderer="string", request_method="GET" 264 | ) 265 | 266 | app_config.make_wsgi_app() 267 | 268 | assert "Endpoint validation against specification is disabled" in caplog.text 269 | 270 | 271 | def test_unconfigured_app( 272 | simple_config: Configurator, caplog: LogCaptureFixture 273 | ) -> None: 274 | """Asserts the app can be created if no spec has been defined.""" 275 | caplog.set_level(logging.INFO) 276 | simple_config.add_route(name="foo", pattern="/foo") 277 | simple_config.add_view( 278 | foo_view, route_name="foo", renderer="string", request_method="OPTIONS" 279 | ) 280 | 281 | simple_config.make_wsgi_app() 282 | assert "pyramid_openapi3 settings not found" in caplog.text 283 | 284 | 285 | @app_config 286 | def test_routes_setting_generation( 287 | app_config: Configurator, request: SubRequest 288 | ) -> None: 289 | """Test the `routes` setting is correctly created after app creation.""" 290 | app_config = request.getfixturevalue(app_config) 291 | 292 | # Test that having multiple routes for a single route / pattern still works 293 | app_config.add_route(name="get_foo", pattern="/foo", request_method="GET") 294 | app_config.add_route(name="create_foo", pattern="/foo", request_method="POST") 295 | 296 | # Test the simple case of having no predicates on a route 297 | app_config.add_route(name="bar", pattern="/bar") 298 | 299 | # Add the views (needed for app creation) 300 | app_config.add_view( 301 | foo_view, route_name="get_foo", renderer="string", request_method="GET" 302 | ) 303 | app_config.add_view( 304 | foo_view, route_name="create_foo", renderer="string", request_method="POST" 305 | ) 306 | app_config.add_view( 307 | bar_view, route_name="bar", renderer="string", request_method="GET" 308 | ) 309 | 310 | app_config.make_wsgi_app() 311 | 312 | settings = app_config.registry.settings["pyramid_openapi3"] 313 | # Assert that the `routes` setting object was created 314 | assert settings.get("routes") is not None 315 | # Assert that all 3 route names are in the `routes` setting 316 | # These should all map to `pyramid_openapi3` since that it the default apiname 317 | assert settings["routes"]["get_foo"] == "pyramid_openapi3" 318 | assert settings["routes"]["create_foo"] == "pyramid_openapi3" 319 | assert settings["routes"]["bar"] == "pyramid_openapi3" 320 | 321 | 322 | def test_root_server_routes(root_server_app_config: Configurator) -> None: 323 | """Test case for when you have a server, but with url of /.""" 324 | root_server_app_config.add_route(name="foo", pattern="/foo") 325 | root_server_app_config.add_route(name="bar", pattern="/bar") 326 | root_server_app_config.add_view( 327 | foo_view, route_name="foo", renderer="string", request_method="OPTIONS" 328 | ) 329 | root_server_app_config.add_view( 330 | bar_view, route_name="bar", renderer="string", request_method="GET" 331 | ) 332 | 333 | root_server_app_config.make_wsgi_app() 334 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_contenttypes.py: -------------------------------------------------------------------------------- 1 | """Test different request body content types.""" 2 | 3 | from pyramid.config import Configurator 4 | from pyramid.request import Request 5 | from pyramid.router import Router 6 | from webob.multidict import MultiDict 7 | from webtest.app import TestApp 8 | 9 | import tempfile 10 | import typing as t 11 | import unittest 12 | 13 | 14 | def app(spec: str) -> Router: 15 | """Prepare a Pyramid app.""" 16 | 17 | def foo_view(request: Request) -> t.Dict[str, str]: 18 | """Return reversed string.""" 19 | return {"bar": request.openapi_validated.body["bar"][::-1]} 20 | 21 | def multipart_view(request: Request) -> t.Dict[str, t.Union[str, t.List[str]]]: 22 | """Return reversed string.""" 23 | body = request.openapi_validated.body 24 | return { 25 | "key1": body["key1"][::-1], 26 | "key2": [x[::-1] for x in body["key2"]], 27 | "key3": body["key3"].decode("utf-8")[::-1], 28 | } 29 | 30 | with Configurator() as config: 31 | config.include("pyramid_openapi3") 32 | config.pyramid_openapi3_spec(spec) 33 | config.add_route("foo", "/foo") 34 | config.add_view( 35 | openapi=True, 36 | renderer="json", 37 | view=foo_view, 38 | route_name="foo", 39 | ) 40 | config.add_route("multipart", "/multipart") 41 | config.add_view( 42 | openapi=True, 43 | renderer="json", 44 | view=multipart_view, 45 | route_name="multipart", 46 | ) 47 | return config.make_wsgi_app() 48 | 49 | 50 | OPENAPI_YAML = """ 51 | openapi: "3.0.0" 52 | info: 53 | version: "1.0.0" 54 | title: Foo 55 | components: 56 | schemas: 57 | FooObject: 58 | type: object 59 | properties: 60 | bar: 61 | type: string 62 | BarObject: 63 | type: object 64 | properties: 65 | key1: 66 | type: string 67 | key2: 68 | type: array 69 | items: 70 | type: string 71 | key3: 72 | type: string 73 | format: binary 74 | paths: 75 | /foo: 76 | post: 77 | requestBody: 78 | content: 79 | application/json: 80 | schema: 81 | $ref: "#/components/schemas/FooObject" 82 | application/x-www-form-urlencoded: 83 | schema: 84 | $ref: "#/components/schemas/FooObject" 85 | responses: 86 | 200: 87 | description: OK 88 | /multipart: 89 | post: 90 | requestBody: 91 | content: 92 | multipart/form-data: 93 | schema: 94 | $ref: "#/components/schemas/BarObject" 95 | responses: 96 | 200: 97 | description: OK 98 | """ 99 | 100 | 101 | class TestContentTypes(unittest.TestCase): 102 | """A suite of tests that make sure different body content types are supported.""" 103 | 104 | def _testapp(self) -> TestApp: 105 | """Start up the app so that tests can send requests to it.""" 106 | from webtest import TestApp 107 | 108 | with tempfile.NamedTemporaryFile() as document: 109 | document.write(OPENAPI_YAML.encode()) 110 | document.seek(0) 111 | 112 | return TestApp(app(document.name)) 113 | 114 | def test_post_json(self) -> None: 115 | """Post with `application/json`.""" 116 | 117 | res = self._testapp().post_json("/foo", {"bar": "baz"}, status=200) 118 | self.assertEqual(res.json, {"bar": "zab"}) 119 | 120 | def test_post_form(self) -> None: # pragma: no cover 121 | """Post with `application/x-www-form-urlencoded`.""" 122 | 123 | res = self._testapp().post("/foo", params={"bar": "baz"}, status=200) 124 | self.assertEqual(res.json, {"bar": "zab"}) 125 | 126 | def test_post_multipart(self) -> None: 127 | """Post with `multipart/form-data`.""" 128 | 129 | multi_dict = MultiDict() 130 | multi_dict.add("key1", "value1") 131 | multi_dict.add("key2", "value2.1") 132 | multi_dict.add("key2", "value2.2") 133 | multi_dict.add("key3", b"value3") 134 | 135 | res = self._testapp().post( 136 | "/multipart", 137 | multi_dict, 138 | content_type="multipart/form-data", 139 | status=200, 140 | ) 141 | self.assertEqual( 142 | res.json, 143 | { 144 | "key1": "1eulav", 145 | "key2": ["1.2eulav", "2.2eulav"], 146 | "key3": "3eulav", 147 | }, 148 | ) 149 | 150 | 151 | # This is almost the same as the previous test, but with OpenAPI 3.1.0. 152 | # `multipart_view()` no longer needs to decode the bytes to a string. 153 | def app310(spec: str) -> Router: 154 | """Prepare a Pyramid app.""" 155 | 156 | def foo_view(request: Request) -> t.Dict[str, str]: 157 | """Return reversed string.""" 158 | return {"bar": request.openapi_validated.body["bar"][::-1]} 159 | 160 | def multipart_view(request: Request) -> t.Dict[str, t.Union[str, t.List[str]]]: 161 | """Return reversed string.""" 162 | body = request.openapi_validated.body 163 | return { 164 | "key1": body["key1"][::-1], 165 | "key2": [x[::-1] for x in body["key2"]], 166 | "key3": body["key3"][::-1], 167 | } 168 | 169 | with Configurator() as config: 170 | config.include("pyramid_openapi3") 171 | config.pyramid_openapi3_spec(spec) 172 | config.add_route("foo", "/foo") 173 | config.add_view( 174 | openapi=True, 175 | renderer="json", 176 | view=foo_view, 177 | route_name="foo", 178 | ) 179 | config.add_route("multipart", "/multipart") 180 | config.add_view( 181 | openapi=True, 182 | renderer="json", 183 | view=multipart_view, 184 | route_name="multipart", 185 | ) 186 | return config.make_wsgi_app() 187 | 188 | 189 | OPENAPI_YAML310 = """ 190 | openapi: "3.1.0" 191 | info: 192 | version: "1.0.0" 193 | title: Foo 194 | components: 195 | schemas: 196 | FooObject: 197 | type: object 198 | properties: 199 | bar: 200 | type: string 201 | BarObject: 202 | type: object 203 | properties: 204 | key1: 205 | type: string 206 | key2: 207 | type: array 208 | items: 209 | type: string 210 | key3: 211 | type: string 212 | contentMediaType: application/octet-stream 213 | paths: 214 | /foo: 215 | post: 216 | requestBody: 217 | content: 218 | application/json: 219 | schema: 220 | $ref: "#/components/schemas/FooObject" 221 | application/x-www-form-urlencoded: 222 | schema: 223 | $ref: "#/components/schemas/FooObject" 224 | responses: 225 | 200: 226 | description: OK 227 | /multipart: 228 | post: 229 | requestBody: 230 | content: 231 | multipart/form-data: 232 | schema: 233 | $ref: "#/components/schemas/BarObject" 234 | responses: 235 | 200: 236 | description: OK 237 | """ 238 | 239 | 240 | class TestContentTypes310(unittest.TestCase): 241 | """A suite of tests that make sure different body content types are supported.""" 242 | 243 | def _testapp(self) -> TestApp: 244 | """Start up the app so that tests can send requests to it.""" 245 | from webtest import TestApp 246 | 247 | with tempfile.NamedTemporaryFile() as document: 248 | document.write(OPENAPI_YAML310.encode()) 249 | document.seek(0) 250 | 251 | return TestApp(app310(document.name)) 252 | 253 | def test_post_json(self) -> None: 254 | """Post with `application/json`.""" 255 | 256 | res = self._testapp().post_json("/foo", {"bar": "baz"}, status=200) 257 | self.assertEqual(res.json, {"bar": "zab"}) 258 | 259 | def test_post_form(self) -> None: # pragma: no cover 260 | """Post with `application/x-www-form-urlencoded`.""" 261 | 262 | res = self._testapp().post("/foo", params={"bar": "baz"}, status=200) 263 | self.assertEqual(res.json, {"bar": "zab"}) 264 | 265 | def test_post_multipart(self) -> None: 266 | """Post with `multipart/form-data`.""" 267 | 268 | multi_dict = MultiDict() 269 | multi_dict.add("key1", "value1") 270 | multi_dict.add("key2", "value2.1") 271 | multi_dict.add("key2", "value2.2") 272 | multi_dict.add("key3", b"value3") 273 | 274 | res = self._testapp().post( 275 | "/multipart", 276 | multi_dict, 277 | content_type="multipart/form-data", 278 | status=200, 279 | ) 280 | self.assertEqual( 281 | res.json, 282 | { 283 | "key1": "1eulav", 284 | "key2": ["1.2eulav", "2.2eulav"], 285 | "key3": "3eulav", 286 | }, 287 | ) 288 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_extract_errors.py: -------------------------------------------------------------------------------- 1 | """Test rendering errors as JSON responses.""" 2 | 3 | from pyramid.config import Configurator 4 | from pyramid.httpexceptions import exception_response 5 | from pyramid.request import Request 6 | from pyramid.router import Router 7 | from pyramid_openapi3.exceptions import InvalidCustomFormatterValue 8 | from pyramid_openapi3.exceptions import RequestValidationError 9 | from webtest.app import TestApp 10 | 11 | import json 12 | import tempfile 13 | import typing as t 14 | import unittest 15 | 16 | 17 | def app(spec: str, view: t.Callable, route: str) -> Router: 18 | """Prepare a Pyramid app.""" 19 | with Configurator() as config: 20 | config.include("pyramid_openapi3") 21 | config.pyramid_openapi3_spec(spec) 22 | config.add_route("foo", route) 23 | config.add_view(openapi=True, renderer="json", view=view, route_name="foo") 24 | return config.make_wsgi_app() 25 | 26 | 27 | class BadRequestsTests(unittest.TestCase): 28 | """A suite of tests that make sure bad requests are handled.""" 29 | 30 | def foo(*args) -> None: # noqa: D102 31 | return None # pragma: no cover 32 | 33 | OPENAPI_YAML = """ 34 | openapi: "3.1.0" 35 | info: 36 | version: "1.0.0" 37 | title: Foo 38 | paths: 39 | {endpoints} 40 | """ 41 | 42 | def _testapp( 43 | self, view: t.Callable, endpoints: str, route: str = "/foo" 44 | ) -> TestApp: 45 | """Start up the app so that tests can send requests to it.""" 46 | from webtest import TestApp 47 | 48 | with tempfile.NamedTemporaryFile() as document: 49 | document.write(self.OPENAPI_YAML.format(endpoints=endpoints).encode()) 50 | document.seek(0) 51 | 52 | return TestApp(app(document.name, view, route)) 53 | 54 | def test_missing_query_parameter(self) -> None: 55 | """Render nice ValidationError if query parameter is missing.""" 56 | endpoints = """ 57 | /foo: 58 | post: 59 | parameters: 60 | - name: bar 61 | in: query 62 | required: true 63 | schema: 64 | type: integer 65 | responses: 66 | 200: 67 | description: Say hello 68 | 400: 69 | description: Bad Request 70 | """ 71 | 72 | res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400) 73 | assert res.json == [ 74 | { 75 | "exception": "MissingRequiredParameter", 76 | "message": "Missing required query parameter: bar", 77 | "field": "bar", 78 | } 79 | ] 80 | 81 | def test_invalid_query_parameter(self) -> None: 82 | """Render nice ValidationError if query parameter is invalid.""" 83 | endpoints = """ 84 | "/foo": 85 | post: 86 | parameters: 87 | - name: bar 88 | in: query 89 | required: true 90 | schema: 91 | type: integer 92 | responses: 93 | 200: 94 | description: Say hello 95 | 400: 96 | description: Bad Request 97 | """ 98 | 99 | res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400) 100 | assert res.json == [ 101 | { 102 | "exception": "MissingRequiredParameter", 103 | "message": "Missing required query parameter: bar", 104 | "field": "bar", 105 | } 106 | ] 107 | 108 | def test_invalid_path_parameter(self) -> None: 109 | """Render nice ValidationError if path parameter is invalid.""" 110 | endpoints = """ 111 | "/foo/{bar}": 112 | post: 113 | parameters: 114 | - name: bar 115 | in: path 116 | required: true 117 | schema: 118 | type: integer 119 | responses: 120 | 200: 121 | description: Say hello 122 | 400: 123 | description: Bad Request 124 | """ 125 | 126 | res = self._testapp( 127 | view=self.foo, endpoints=endpoints, route="/foo/{bar}" 128 | ).post("/foo/not_a_number", status=400) 129 | assert res.json == [ 130 | { 131 | "exception": "ParameterValidationError", 132 | "message": "Failed to cast value to integer type: not_a_number", 133 | "field": "bar", 134 | } 135 | ] 136 | 137 | def test_invalid_path_parameter_regex(self) -> None: 138 | """Render nice ValidationError if path parameter does not match regex.""" 139 | endpoints = """ 140 | "/foo/{bar}": 141 | post: 142 | parameters: 143 | - name: bar 144 | in: path 145 | required: true 146 | schema: 147 | type: string 148 | pattern: '^[0-9]{2}-[A-F]{4}$' 149 | responses: 150 | 200: 151 | description: Say hello 152 | 400: 153 | description: Bad Request 154 | """ 155 | 156 | res = self._testapp( 157 | view=self.foo, endpoints=endpoints, route="/foo/{bar}" 158 | ).post("/foo/not-a-valid-uuid", status=400) 159 | assert res.json == [ 160 | { 161 | "exception": "ValidationError", 162 | "message": "'not-a-valid-uuid' does not match '^[0-9]{2}-[A-F]{4}$'", 163 | "field": "bar", 164 | } 165 | ] 166 | 167 | def test_invalid_path_parameter_uuid(self) -> None: 168 | """Render nice ValidationError if path parameter is not UUID.""" 169 | endpoints = """ 170 | "/foo/{bar}": 171 | post: 172 | parameters: 173 | - name: bar 174 | in: path 175 | required: true 176 | schema: 177 | type: string 178 | format: uuid 179 | responses: 180 | 200: 181 | description: Say hello 182 | 400: 183 | description: Bad Request 184 | """ 185 | 186 | res = self._testapp( 187 | view=self.foo, endpoints=endpoints, route="/foo/{bar}" 188 | ).post("/foo/not-a-valid-uuid", status=400) 189 | assert res.json == [ 190 | { 191 | "exception": "ValidationError", 192 | "message": "badly formed hexadecimal UUID string", 193 | "field": "bar", 194 | } 195 | ] 196 | 197 | def test_missing_header_parameter(self) -> None: 198 | """Render nice ValidationError if header parameter is missing.""" 199 | endpoints = """ 200 | "/foo": 201 | post: 202 | parameters: 203 | - name: bar 204 | in: header 205 | required: true 206 | schema: 207 | type: integer 208 | responses: 209 | 200: 210 | description: Say hello 211 | 400: 212 | description: Bad Request 213 | """ 214 | 215 | res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400) 216 | assert res.json == [ 217 | { 218 | "exception": "MissingRequiredParameter", 219 | "message": "Missing required header parameter: bar", 220 | "field": "bar", 221 | } 222 | ] 223 | 224 | def test_missing_cookie_parameter(self) -> None: 225 | """Render nice ValidationError if cookie parameter is missing.""" 226 | endpoints = """ 227 | "/foo": 228 | post: 229 | parameters: 230 | - name: bar 231 | in: cookie 232 | required: true 233 | schema: 234 | type: integer 235 | responses: 236 | 200: 237 | description: Say hello 238 | 400: 239 | description: Bad Request 240 | """ 241 | 242 | res = self._testapp(view=self.foo, endpoints=endpoints).post("/foo", status=400) 243 | assert res.json == [ 244 | { 245 | "exception": "MissingRequiredParameter", 246 | "message": "Missing required cookie parameter: bar", 247 | "field": "bar", 248 | } 249 | ] 250 | 251 | def test_missing_POST_parameter(self) -> None: 252 | """Render nice ValidationError if POST parameter is missing.""" 253 | endpoints = """ 254 | "/foo": 255 | post: 256 | requestBody: 257 | required: true 258 | description: Data for saying foo 259 | content: 260 | application/json: 261 | schema: 262 | type: object 263 | required: 264 | - foo 265 | properties: 266 | foo: 267 | type: string 268 | responses: 269 | 200: 270 | description: Say hello 271 | 400: 272 | description: Bad Request 273 | """ 274 | 275 | res = self._testapp(view=self.foo, endpoints=endpoints).post_json( 276 | "/foo", {}, status=400 277 | ) 278 | assert res.json == [ 279 | { 280 | "exception": "ValidationError", 281 | "message": "'foo' is a required property", 282 | "field": "foo", 283 | } 284 | ] 285 | 286 | def test_missing_type_POST_parameter(self) -> None: 287 | """Render nice ValidationError if POST parameter is of invalid type.""" 288 | endpoints = """ 289 | "/foo": 290 | post: 291 | requestBody: 292 | required: true 293 | description: Data for saying foo 294 | content: 295 | application/json: 296 | schema: 297 | type: object 298 | required: 299 | - foo 300 | properties: 301 | foo: 302 | type: string 303 | responses: 304 | 200: 305 | description: Say hello 306 | 400: 307 | description: Bad Request 308 | """ 309 | 310 | res = self._testapp(view=self.foo, endpoints=endpoints).post_json( 311 | "/foo", {"foo": 1}, status=400 312 | ) 313 | assert res.json == [ 314 | { 315 | "exception": "ValidationError", 316 | "message": "1 is not of type 'string'", 317 | "field": "foo", 318 | } 319 | ] 320 | 321 | def test_invalid_length_POST_parameter(self) -> None: 322 | """Render nice ValidationError if POST parameter is of invalid length.""" 323 | endpoints = """ 324 | "/foo": 325 | post: 326 | requestBody: 327 | required: true 328 | description: Data for saying foo 329 | content: 330 | application/json: 331 | schema: 332 | type: object 333 | properties: 334 | foo: 335 | type: string 336 | minLength: 3 337 | responses: 338 | 200: 339 | description: Say hello 340 | 400: 341 | description: Bad Request 342 | """ 343 | 344 | res = self._testapp(view=self.foo, endpoints=endpoints).post_json( 345 | "/foo", {"foo": "12"}, status=400 346 | ) 347 | assert res.json == [ 348 | { 349 | "exception": "ValidationError", 350 | "message": "'12' is too short", 351 | "field": "foo", 352 | } 353 | ] 354 | 355 | def test_multiple_errors(self) -> None: 356 | """Render a list of errors if there are more than one.""" 357 | endpoints = """ 358 | /foo: 359 | post: 360 | requestBody: 361 | required: true 362 | description: Data for saying foo 363 | content: 364 | application/json: 365 | schema: 366 | type: object 367 | properties: 368 | foo: 369 | type: string 370 | minLength: 5 371 | maxLength: 3 372 | parameters: 373 | - name: bar 374 | in: query 375 | required: true 376 | schema: 377 | type: string 378 | - name: bam 379 | in: query 380 | schema: 381 | type: integer 382 | responses: 383 | 200: 384 | description: Say hello 385 | 400: 386 | description: Bad Request 387 | """ 388 | res = self._testapp(view=self.foo, endpoints=endpoints).post_json( 389 | "/foo?bam=abc", {"foo": "1234"}, status=400 390 | ) 391 | assert res.json == [ 392 | { 393 | "exception": "MissingRequiredParameter", 394 | "message": "Missing required query parameter: bar", 395 | "field": "bar", 396 | }, 397 | { 398 | "exception": "ParameterValidationError", 399 | "message": "Failed to cast value to integer type: abc", 400 | "field": "bam", 401 | }, 402 | { 403 | "exception": "ValidationError", 404 | "message": "'1234' is too short", 405 | "field": "foo", 406 | }, 407 | { 408 | "exception": "ValidationError", 409 | "message": "'1234' is too long", 410 | "field": "foo", 411 | }, 412 | ] 413 | 414 | def test_bad_JWT_token(self) -> None: 415 | """Render 401 on bad JWT token.""" 416 | endpoints = """ 417 | /foo: 418 | get: 419 | security: 420 | - Token: 421 | [] 422 | responses: 423 | 200: 424 | description: Say hello 425 | 401: 426 | description: Unauthorized 427 | components: 428 | securitySchemes: 429 | Token: 430 | type: apiKey 431 | name: Authorization 432 | in: header 433 | """ 434 | res = self._testapp(view=self.foo, endpoints=endpoints).get("/foo", status=401) 435 | assert res.json == [ 436 | { 437 | "exception": "SecurityValidationError", 438 | "message": "Security not found. Schemes not valid for any requirement: [['Token']]", 439 | } 440 | ] 441 | 442 | def test_lists(self) -> None: 443 | """Error extracting works for lists too.""" 444 | endpoints = """ 445 | /foo: 446 | post: 447 | requestBody: 448 | description: A list of bars 449 | content: 450 | application/json: 451 | schema: 452 | required: 453 | - foo 454 | type: object 455 | properties: 456 | foo: 457 | type: array 458 | items: 459 | $ref: "#/components/schemas/bar" 460 | responses: 461 | 200: 462 | description: Say hello 463 | 400: 464 | description: Bad Request 465 | components: 466 | schemas: 467 | bar: 468 | required: 469 | - bam 470 | type: object 471 | properties: 472 | bam: 473 | type: number 474 | """ 475 | res = self._testapp(view=self.foo, endpoints=endpoints).post_json( 476 | "/foo", {"foo": [{"bam": "not a number"}]}, status=400 477 | ) 478 | 479 | assert res.json == [ 480 | { 481 | "exception": "RequestBodyValidationError", 482 | "message": "Failed to cast value to number type: not a number", 483 | } 484 | ] 485 | 486 | 487 | class BadResponsesTests(unittest.TestCase): 488 | """A suite of tests that make sure bad responses are prevented.""" 489 | 490 | OPENAPI_YAML = b""" 491 | openapi: "3.1.0" 492 | info: 493 | version: "1.0.0" 494 | title: Foo 495 | paths: 496 | /foo: 497 | get: 498 | responses: 499 | 200: 500 | description: Say foo 501 | 400: 502 | description: Bad Request 503 | content: 504 | application/json: 505 | schema: 506 | type: string 507 | """ 508 | 509 | def _testapp(self, view: t.Callable) -> TestApp: 510 | """Start up the app so that tests can send requests to it.""" 511 | from webtest import TestApp 512 | 513 | with tempfile.NamedTemporaryFile() as document: 514 | document.write(self.OPENAPI_YAML) 515 | document.seek(0) 516 | 517 | return TestApp(app(document.name, view, route="/foo")) 518 | 519 | def test_foo(self) -> None: 520 | """Say foo.""" 521 | 522 | def foo(*args: t.Any) -> t.Dict[str, str]: 523 | """Say foobar.""" 524 | return {"foo": "bar"} 525 | 526 | res = self._testapp(view=foo).get("/foo", status=200) 527 | self.assertIn('{"foo": "bar"}', res.text) 528 | 529 | def test_invalid_response_code(self) -> None: 530 | """Prevent responding with undefined response code.""" 531 | 532 | def foo(*args: t.Any) -> Exception: 533 | raise exception_response(409, json_body={}) 534 | 535 | res = self._testapp(view=foo).get("/foo", status=500) 536 | assert res.json == [ 537 | { 538 | "exception": "ResponseNotFound", 539 | "message": "Unknown response http status: 409", 540 | } 541 | ] 542 | 543 | def test_invalid_response_schema(self) -> None: 544 | """Prevent responding with unmatching response schema.""" 545 | from pyramid.httpexceptions import exception_response 546 | 547 | def foo(*args: t.Any) -> Exception: 548 | raise exception_response(400, json_body={"foo": "bar"}) 549 | 550 | res = self._testapp(view=foo).get("/foo", status=500) 551 | assert res.json == [ 552 | { 553 | "exception": "ValidationError", 554 | "message": "{'foo': 'bar'} is not of type 'string'", 555 | } 556 | ] 557 | 558 | 559 | class CustomFormattersTests(unittest.TestCase): 560 | """A suite of tests that showcase how custom formatters can be used.""" 561 | 562 | def hello(self, context: t.Any, request: Request) -> str: 563 | """Say hello.""" 564 | return f"Hello {request.openapi_validated.body['name']}" 565 | 566 | def unique_name(self, name: str) -> bool: 567 | """Ensure name is unique.""" 568 | if not isinstance(name, str): 569 | return True # Only check strings (let default validation handle others) 570 | 571 | name = name.lower() 572 | if name in ["alice", "bob"]: 573 | raise RequestValidationError( 574 | errors=[ 575 | InvalidCustomFormatterValue( 576 | value=name, 577 | type="unique-name", 578 | original_exception=Exception( 579 | f"Name '{name}' already taken. Choose a different name!" 580 | ), 581 | field="name", 582 | ) 583 | ] 584 | ) 585 | return True 586 | 587 | OPENAPI_YAML = """ 588 | openapi: "3.1.0" 589 | info: 590 | version: "1.0.0" 591 | title: Foo 592 | paths: 593 | /hello: 594 | post: 595 | requestBody: 596 | required: true 597 | content: 598 | application/json: 599 | schema: 600 | type: object 601 | required: 602 | - name 603 | properties: 604 | name: 605 | type: string 606 | minLength: 3 607 | format: unique-name 608 | responses: 609 | 200: 610 | description: Say hello 611 | 400: 612 | description: Bad Request 613 | """ 614 | 615 | def _testapp(self) -> TestApp: 616 | """Start up the app so that tests can send requests to it.""" 617 | from webtest import TestApp 618 | 619 | with tempfile.NamedTemporaryFile() as document: 620 | document.write(self.OPENAPI_YAML.encode()) 621 | document.seek(0) 622 | 623 | with Configurator() as config: 624 | config.include("pyramid_openapi3") 625 | config.pyramid_openapi3_spec(document.name) 626 | config.pyramid_openapi3_add_formatter("unique-name", self.unique_name) 627 | config.add_route("hello", "/hello") 628 | config.add_view( 629 | openapi=True, renderer="json", view=self.hello, route_name="hello" 630 | ) 631 | app = config.make_wsgi_app() 632 | 633 | return TestApp(app) 634 | 635 | def test_say_hello(self) -> None: 636 | """Test happy path.""" 637 | res = self._testapp().post_json("/hello", {"name": "zupo"}, status=200) 638 | assert res.json == "Hello zupo" 639 | 640 | def test_name_taken(self) -> None: 641 | """Test passing a name that is taken.""" 642 | res = self._testapp().post_json("/hello", {"name": "Alice"}, status=400) 643 | assert res.json == [ 644 | { 645 | "exception": "InvalidCustomFormatterValue", 646 | "field": "name", 647 | "message": "Name 'alice' already taken. Choose a different name!", 648 | } 649 | ] 650 | 651 | def test_invalid_name(self) -> None: 652 | """Test that built-in type formatters do their job.""" 653 | res = self._testapp().post_json("/hello", {"name": 12}, status=400) 654 | assert res.json == [ 655 | { 656 | "exception": "ValidationError", 657 | "message": "12 is not of type 'string'", 658 | "field": "name", 659 | } 660 | ] 661 | 662 | res = self._testapp().post_json("/hello", {"name": "yo"}, status=400) 663 | assert res.json == [ 664 | { 665 | "exception": "ValidationError", 666 | "message": "'yo' is too short", 667 | "field": "name", 668 | } 669 | ] 670 | 671 | 672 | class CustomDeserializerTests(unittest.TestCase): 673 | """A suite of tests that showcase how custom deserializers can be used.""" 674 | 675 | def hello(self, context: t.Any, request: Request) -> str: 676 | """Say hello.""" 677 | result = self.reverse(f"Hello {request.openapi_validated.body['name']}") 678 | request.response.content_type = "application/backwards+json" 679 | return result 680 | 681 | @staticmethod 682 | def reverse(s: str) -> str: 683 | """Reverse a string.""" 684 | return s[::-1] 685 | 686 | OPENAPI_YAML = """ 687 | openapi: "3.1.0" 688 | info: 689 | version: "1.0.0" 690 | title: Foo 691 | paths: 692 | /hello: 693 | post: 694 | requestBody: 695 | required: true 696 | content: 697 | application/backwards+json: 698 | schema: 699 | type: object 700 | required: 701 | - name 702 | properties: 703 | name: 704 | type: string 705 | responses: 706 | 200: 707 | description: Say hello 708 | content: 709 | application/backwards+json: 710 | schema: 711 | type: string 712 | 400: 713 | description: Bad Request 714 | """ 715 | 716 | def _testapp(self) -> TestApp: 717 | """Start up the app so that tests can send requests to it.""" 718 | from webtest import TestApp 719 | 720 | with tempfile.NamedTemporaryFile() as document: 721 | document.write(self.OPENAPI_YAML.encode()) 722 | document.seek(0) 723 | 724 | with Configurator() as config: 725 | config.include("pyramid_openapi3") 726 | config.pyramid_openapi3_spec(document.name) 727 | config.pyramid_openapi3_add_deserializer( 728 | "application/backwards+json", lambda x: json.loads(self.reverse(x)) 729 | ) 730 | config.add_route("hello", "/hello") 731 | config.add_view( 732 | openapi=True, renderer="json", view=self.hello, route_name="hello" 733 | ) 734 | app = config.make_wsgi_app() 735 | 736 | return TestApp(app) 737 | 738 | def test_say_hello(self) -> None: 739 | """Test happy path.""" 740 | 741 | headers = {"Content-Type": "application/backwards+json"} 742 | body = self.reverse(json.dumps({"name": "zupo"})) 743 | res = self._testapp().post("/hello", body, headers, status=200) 744 | assert res.json == self.reverse("Hello zupo") 745 | 746 | 747 | class CustomUnmarshallersTests(unittest.TestCase): 748 | """A suite of tests that showcase how custom unmarshallers can be used.""" 749 | 750 | def hello(self, context: t.Any, request: Request) -> str: 751 | """Say hello.""" 752 | return f"Hello {request.openapi_validated.body['id']}" 753 | 754 | def parse_id(self, id_: str) -> str: 755 | """Expand id parameter.""" 756 | return id_.strip("[]").replace(",", " and ") 757 | 758 | OPENAPI_YAML = """ 759 | openapi: "3.1.0" 760 | info: 761 | version: "1.0.0" 762 | title: Foo 763 | paths: 764 | /hello: 765 | post: 766 | requestBody: 767 | required: true 768 | content: 769 | application/json: 770 | schema: 771 | type: object 772 | required: 773 | - id 774 | properties: 775 | id: 776 | type: string 777 | format: parse-id 778 | responses: 779 | 200: 780 | description: Say hello 781 | 400: 782 | description: Bad Request 783 | """ 784 | 785 | def _testapp(self) -> TestApp: 786 | """Start up the app so that tests can send requests to it.""" 787 | from webtest import TestApp 788 | 789 | with tempfile.NamedTemporaryFile() as document: 790 | document.write(self.OPENAPI_YAML.encode()) 791 | document.seek(0) 792 | 793 | with Configurator() as config: 794 | config.include("pyramid_openapi3") 795 | config.pyramid_openapi3_spec(document.name) 796 | config.pyramid_openapi3_add_unmarshaller("parse-id", self.parse_id) 797 | config.add_route("hello", "/hello") 798 | config.add_view( 799 | openapi=True, renderer="json", view=self.hello, route_name="hello" 800 | ) 801 | app = config.make_wsgi_app() 802 | 803 | return TestApp(app) 804 | 805 | def test_say_hello(self) -> None: 806 | """Test happy path.""" 807 | res = self._testapp().post_json("/hello", {"id": "[1,2,3]"}, status=200) 808 | assert res.json == "Hello 1 and 2 and 3" 809 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_path_parameters.py: -------------------------------------------------------------------------------- 1 | """Test path-level parameters.""" 2 | 3 | from pyramid.config import Configurator 4 | from pyramid.request import Request 5 | from tempfile import NamedTemporaryFile 6 | from webtest.app import TestApp 7 | 8 | 9 | def _foo_view(request: Request) -> int: 10 | return request.openapi_validated.parameters.path["foo_id"] 11 | 12 | 13 | def test_path_parameter_validation() -> None: 14 | """Test validated parameters in context factory.""" 15 | 16 | with NamedTemporaryFile() as tempdoc: 17 | tempdoc.write( 18 | b"""\ 19 | openapi: "3.1.0" 20 | info: 21 | version: "1.0.0" 22 | title: Foo API 23 | paths: 24 | /foo/{foo_id}: 25 | parameters: 26 | - name: foo_id 27 | in: path 28 | required: true 29 | schema: 30 | type: integer 31 | get: 32 | responses: 33 | 200: 34 | description: A foo 35 | """ 36 | ) 37 | tempdoc.seek(0) 38 | 39 | with Configurator() as config: 40 | config.include("pyramid_openapi3") 41 | config.pyramid_openapi3_spec(tempdoc.name) 42 | config.pyramid_openapi3_register_routes() 43 | config.add_route("foo_route", "/foo/{foo_id}") 44 | config.add_view( 45 | _foo_view, route_name="foo_route", renderer="json", openapi=True 46 | ) 47 | app = config.make_wsgi_app() 48 | test_app = TestApp(app) 49 | resp = test_app.get("/foo/1") 50 | assert resp.json == 1 51 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | """Test rendering with permissions.""" 2 | 3 | from pyramid.authentication import SessionAuthenticationPolicy 4 | from pyramid.authorization import ACLAuthorizationPolicy 5 | from pyramid.config import Configurator 6 | from pyramid.request import Request 7 | from pyramid.security import Allow 8 | from pyramid.security import Authenticated 9 | from pyramid.security import NO_PERMISSION_REQUIRED 10 | from pyramid.session import SignedCookieSessionFactory 11 | from webtest.app import TestApp 12 | 13 | import os 14 | import pytest 15 | import tempfile 16 | 17 | DEFAULT_ACL = [ 18 | (Allow, Authenticated, "view"), 19 | ] 20 | 21 | 22 | class DummyDefaultContext: # noqa: D101 23 | 24 | __acl__ = DEFAULT_ACL 25 | 26 | 27 | def get_default_context(request: Request) -> DummyDefaultContext: 28 | """Return a dummy context.""" 29 | return DummyDefaultContext() 30 | 31 | 32 | @pytest.fixture 33 | def simple_config() -> Configurator: 34 | """Prepare the base configuration needed for the Pyramid app.""" 35 | with Configurator() as config: 36 | config.include("pyramid_openapi3") 37 | 38 | # Setup security 39 | config.set_default_permission("view") 40 | config.set_session_factory(SignedCookieSessionFactory("itsaseekreet")) 41 | config.set_authentication_policy(SessionAuthenticationPolicy()) 42 | config.set_authorization_policy(ACLAuthorizationPolicy()) 43 | config.set_root_factory(get_default_context) 44 | 45 | yield config 46 | 47 | 48 | OPENAPI_YAML = """ 49 | openapi: "3.1.0" 50 | info: 51 | version: "1.0.0" 52 | title: Foo 53 | paths: 54 | /foo: 55 | post: 56 | parameters: 57 | - name: bar 58 | in: query 59 | schema: 60 | type: integer 61 | responses: 62 | 200: 63 | description: Say hello 64 | """ 65 | 66 | 67 | @pytest.mark.parametrize( 68 | "route,permission,status", 69 | ( 70 | ("/api/v1/openapi.yaml", None, 403), 71 | ("/api/v1/openapi.yaml", NO_PERMISSION_REQUIRED, 200), 72 | ("/api/v1/", None, 403), 73 | ("/api/v1/", NO_PERMISSION_REQUIRED, 200), 74 | ), 75 | ) 76 | def test_permission_for_specs( 77 | simple_config: Configurator, route: str, permission: str, status: int 78 | ) -> None: 79 | """Allow (200) or deny (403) access to the spec/explorer view.""" 80 | 81 | with tempfile.NamedTemporaryFile() as document: 82 | document.write(OPENAPI_YAML.encode()) 83 | document.seek(0) 84 | 85 | simple_config.pyramid_openapi3_spec( 86 | document.name, 87 | route="/api/v1/openapi.yaml", 88 | route_name="api_spec", 89 | permission=permission, 90 | ) 91 | simple_config.pyramid_openapi3_add_explorer( 92 | route="/api/v1/", 93 | route_name="api_explorer", 94 | permission=permission, 95 | ) 96 | simple_config.add_route("foo", "/foo") 97 | 98 | testapp = TestApp(simple_config.make_wsgi_app()) 99 | 100 | testapp.get(route, status=status) 101 | 102 | 103 | SPLIT_OPENAPI_YAML = b""" 104 | openapi: "3.1.0" 105 | info: 106 | version: "1.0.0" 107 | title: Foo API 108 | paths: 109 | /foo: 110 | $ref: "paths.yaml#/foo" 111 | """ 112 | 113 | SPLIT_PATHS_YAML = b""" 114 | foo: 115 | post: 116 | parameters: 117 | - name: bar 118 | in: query 119 | schema: 120 | type: integer 121 | responses: 122 | 200: 123 | description: Say hello 124 | """ 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "route,permission,status", 129 | ( 130 | ("/api/v1/spec/openapi.yaml", "deny", 403), 131 | ("/api/v1/spec/openapi.yaml", NO_PERMISSION_REQUIRED, 200), 132 | ("/api/v1/spec/paths.yaml", "deny", 403), 133 | ("/api/v1/spec/paths.yaml", NO_PERMISSION_REQUIRED, 200), 134 | ("/api/v1/", "deny", 403), 135 | ("/api/v1/", NO_PERMISSION_REQUIRED, 200), 136 | ), 137 | ) 138 | def test_permission_for_spec_directories( 139 | simple_config: Configurator, route: str, permission: str, status: int 140 | ) -> None: 141 | """Allow (200) or deny (403) access to the spec/explorer view.""" 142 | with tempfile.TemporaryDirectory() as directory: 143 | spec_name = os.path.join(directory, "openapi.yaml") 144 | spec_paths_name = os.path.join(directory, "paths.yaml") 145 | with open(spec_name, "wb") as f: 146 | f.write(SPLIT_OPENAPI_YAML) 147 | with open(spec_paths_name, "wb") as f: 148 | f.write(SPLIT_PATHS_YAML) 149 | 150 | simple_config.pyramid_openapi3_spec_directory( 151 | spec_name, 152 | route="/api/v1/spec", 153 | route_name="api_spec", 154 | permission=permission, 155 | ) 156 | simple_config.pyramid_openapi3_add_explorer( 157 | route="/api/v1/", 158 | route_name="api_explorer", 159 | permission=permission, 160 | ) 161 | simple_config.add_route("foo", "/foo") 162 | 163 | testapp = TestApp(simple_config.make_wsgi_app()) 164 | 165 | testapp.get(route, status=status) 166 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_routes.py: -------------------------------------------------------------------------------- 1 | """Tests routes.""" 2 | 3 | from pyramid.request import Request 4 | from pyramid.testing import testConfig 5 | 6 | import tempfile 7 | 8 | 9 | def dummy_factory(request: Request) -> str: 10 | """Root factory for testing.""" 11 | return "_DUMMY_" # pragma: no cover 12 | 13 | 14 | def test_register_routes_simple() -> None: 15 | """Test registration routes without root_factory.""" 16 | with testConfig() as config: 17 | config.include("pyramid_openapi3") 18 | with tempfile.NamedTemporaryFile() as tempdoc: 19 | tempdoc.write( 20 | b"""\ 21 | openapi: "3.1.0" 22 | info: 23 | version: "1.0.0" 24 | title: Foo API 25 | paths: 26 | /foo: 27 | x-pyramid-route-name: foo 28 | get: 29 | responses: 30 | 200: 31 | description: A foo 32 | /bar: 33 | get: 34 | responses: 35 | 200: 36 | description: A bar 37 | """ 38 | ) 39 | tempdoc.seek(0) 40 | config.pyramid_openapi3_spec(tempdoc.name) 41 | config.pyramid_openapi3_register_routes() 42 | config.add_route("bar", "/bar") 43 | app = config.make_wsgi_app() 44 | 45 | routes = [ 46 | (i["introspectable"]["name"], i["introspectable"]["pattern"]) 47 | for i in app.registry.introspector.get_category("routes") 48 | ] 49 | assert routes == [ 50 | ("pyramid_openapi3.spec", "/openapi.yaml"), 51 | ("foo", "/foo"), 52 | ("bar", "/bar"), 53 | ] 54 | 55 | 56 | def test_register_routes_with_factory() -> None: 57 | """Test registration routes with root_factory.""" 58 | with testConfig() as config: 59 | config.include("pyramid_openapi3") 60 | with tempfile.NamedTemporaryFile() as tempdoc: 61 | tempdoc.write( 62 | b"""\ 63 | openapi: "3.1.0" 64 | info: 65 | version: "1.0.0" 66 | title: Foo API 67 | paths: 68 | /foo: 69 | x-pyramid-route-name: foo 70 | get: 71 | responses: 72 | 200: 73 | description: A foo 74 | /bar: 75 | x-pyramid-route-name: bar 76 | x-pyramid-root-factory: pyramid_openapi3.tests.test_routes.dummy_factory 77 | get: 78 | responses: 79 | 200: 80 | description: A bar 81 | 82 | """ 83 | ) 84 | tempdoc.seek(0) 85 | config.pyramid_openapi3_spec(tempdoc.name) 86 | config.pyramid_openapi3_register_routes() 87 | app = config.make_wsgi_app() 88 | 89 | routes = [ 90 | ( 91 | i["introspectable"]["name"], 92 | i["introspectable"]["pattern"], 93 | i["introspectable"]["factory"], 94 | ) 95 | for i in app.registry.introspector.get_category("routes") 96 | ] 97 | assert routes == [ 98 | ("pyramid_openapi3.spec", "/openapi.yaml", None), 99 | ("foo", "/foo", None), 100 | ("bar", "/bar", dummy_factory), 101 | ] 102 | 103 | 104 | def test_register_routes_with_prefix() -> None: 105 | """Test registration routes with route_prefix.""" 106 | with testConfig() as config: 107 | config.include("pyramid_openapi3") 108 | with tempfile.NamedTemporaryFile() as tempdoc: 109 | tempdoc.write( 110 | b"""\ 111 | openapi: "3.1.0" 112 | info: 113 | version: "1.0.0" 114 | title: Foo API 115 | servers: 116 | - url: /api/v1 117 | paths: 118 | /foo: 119 | x-pyramid-route-name: foo 120 | get: 121 | responses: 122 | 200: 123 | description: A foo 124 | """ 125 | ) 126 | tempdoc.seek(0) 127 | config.pyramid_openapi3_spec(tempdoc.name) 128 | config.pyramid_openapi3_register_routes(route_prefix="/api/v1") 129 | app = config.make_wsgi_app() 130 | 131 | routes = [ 132 | (i["introspectable"]["name"], i["introspectable"]["pattern"]) 133 | for i in app.registry.introspector.get_category("routes") 134 | ] 135 | assert routes == [ 136 | ("pyramid_openapi3.spec", "/openapi.yaml"), 137 | ("foo", "/api/v1/foo"), 138 | ] 139 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_validation.py: -------------------------------------------------------------------------------- 1 | """Test validation exceptions.""" 2 | 3 | from dataclasses import dataclass 4 | from pyramid.interfaces import IRouteRequest 5 | from pyramid.interfaces import IView 6 | from pyramid.interfaces import IViewClassifier 7 | from pyramid.request import apply_request_extensions 8 | from pyramid.request import Request 9 | from pyramid.response import Response 10 | from pyramid.router import Router 11 | from pyramid.testing import DummyRequest 12 | from pyramid.testing import setUp 13 | from pyramid.testing import tearDown 14 | from unittest import TestCase 15 | from zope.interface import Interface 16 | 17 | import json 18 | import tempfile 19 | import typing as t 20 | import warnings 21 | 22 | View = t.Callable[[t.Any, Request], Response] 23 | 24 | 25 | @dataclass 26 | class DummyRoute: # noqa: D101 27 | name: str 28 | pattern: str 29 | 30 | 31 | class DummyStartResponse: # noqa: D101 32 | def __call__(self, status: str, headerlist: t.List[t.Tuple[str, str]]) -> None: 33 | """WSGI start_response protocol.""" 34 | self.status = status 35 | self.headerlist = headerlist 36 | 37 | 38 | class RequestValidationBase(TestCase): # noqa: D101 39 | openapi_spec: bytes 40 | 41 | def setUp(self) -> None: 42 | """unittest.TestCase setUp for each test method. 43 | 44 | Setup a minimal pyramid configuration. 45 | """ 46 | self.config = setUp() 47 | 48 | self.config.include("pyramid_openapi3") 49 | 50 | with tempfile.NamedTemporaryFile() as document: 51 | document.write(self.openapi_spec) 52 | document.seek(0) 53 | 54 | self.config.pyramid_openapi3_spec( 55 | document.name, route="/foo.yaml", route_name="foo_api_spec" 56 | ) 57 | 58 | def tearDown(self) -> None: 59 | """unittest.TestCase tearDown for each test method. 60 | 61 | Tear down everything set up in setUp. 62 | """ 63 | tearDown() 64 | self.config = None 65 | 66 | def _add_view( 67 | self, view_func: t.Optional[t.Callable] = None, openapi: bool = True 68 | ) -> None: 69 | """Add a simple example view. 70 | 71 | :param view_func: an optional view callable. 72 | :param openapi: if True, enable openapi view deriver 73 | """ 74 | self.config.add_route("foo", "/foo") 75 | if not view_func: 76 | view_func = lambda *arg: "foo" # noqa: E731 # pragma: no branch 77 | self.config.add_view( 78 | openapi=openapi, renderer="json", view=view_func, route_name="foo" 79 | ) 80 | 81 | def _get_view(self) -> View: 82 | """Return wrapped view method registered in _add_view.""" 83 | request_interface = self.config.registry.queryUtility(IRouteRequest, name="foo") 84 | view = self.config.registry.adapters.registered( 85 | (IViewClassifier, request_interface, Interface), IView, name="" 86 | ) 87 | return view 88 | 89 | def _get_request(self, params: t.Optional[t.Dict] = None) -> DummyRequest: 90 | """Create a DummyRequest instance matching example view. 91 | 92 | :param params: Query parameter dictionary 93 | """ 94 | request = DummyRequest( 95 | config=self.config, params=params, content_type="text/html" 96 | ) 97 | apply_request_extensions(request) 98 | request.matched_route = DummyRoute(name="foo", pattern="/foo") 99 | return request 100 | 101 | 102 | class TestRequestValidation(RequestValidationBase): # noqa: D101 103 | 104 | openapi_spec = ( 105 | b"openapi: '3.1.0'\n" 106 | b"info:\n" 107 | b" version: '1.0.0'\n" 108 | b" title: Foo API\n" 109 | b"paths:\n" 110 | b" /foo:\n" 111 | b" get:\n" 112 | b" parameters:\n" 113 | b" - name: bar\n" 114 | b" in: query\n" 115 | b" required: true\n" 116 | b" schema:\n" 117 | b" type: integer\n" 118 | b" responses:\n" 119 | b" 200:\n" 120 | b" description: A foo\n" 121 | b" content:\n" 122 | b" application/json:\n" 123 | b" schema:\n" 124 | b" type: object\n" 125 | b" properties:\n" 126 | b" test:\n" 127 | b" type: string\n" 128 | b" 400:\n" 129 | b" description: Bad Request\n" 130 | ) 131 | 132 | def test_view_raises_valid_http_exception(self) -> None: 133 | """Test View raises HTTPException. 134 | 135 | Example view raises a defined response code. 136 | """ 137 | from pyramid.httpexceptions import HTTPBadRequest 138 | 139 | def view_func(*args: t.Any) -> Exception: 140 | raise HTTPBadRequest("bad foo request") 141 | 142 | self._add_view(view_func) 143 | view = self._get_view() 144 | request = self._get_request(params={"bar": "1"}) 145 | with self.assertRaises(HTTPBadRequest) as cm: 146 | view(None, request) 147 | response = cm.exception 148 | # not enough of pyramid has been set up so we need to render the 149 | # exception response ourselves. 150 | response.prepare({"HTTP_ACCEPT": "application/json"}) 151 | self.assertIn("bad foo request", response.json["message"]) 152 | 153 | def test_view_valid_request_response(self) -> None: 154 | """Test openapi validated view which has correct request and response.""" 155 | self._add_view(lambda *arg: {"test": "correct"}) 156 | # run request through router 157 | router = Router(self.config.registry) 158 | environ = { 159 | "wsgi.url_scheme": "http", 160 | "SERVER_NAME": "localhost", 161 | "SERVER_PORT": "8080", 162 | "REQUEST_METHOD": "GET", 163 | "PATH_INFO": "/foo", 164 | "HTTP_ACCEPT": "application/json", 165 | "QUERY_STRING": "bar=1", 166 | } 167 | start_response = DummyStartResponse() 168 | response = router(environ, start_response) 169 | self.assertEqual(start_response.status, "200 OK") 170 | self.assertEqual(json.loads(response[0]), {"test": "correct"}) 171 | 172 | def test_request_validation_error(self) -> None: 173 | """Request validation errors are rendered as 400 JSON responses.""" 174 | self._add_view() 175 | # run request through router 176 | router = Router(self.config.registry) 177 | environ = { 178 | "wsgi.url_scheme": "http", 179 | "SERVER_NAME": "localhost", 180 | "SERVER_PORT": "8080", 181 | "REQUEST_METHOD": "GET", 182 | "PATH_INFO": "/foo", 183 | "HTTP_ACCEPT": "application/json", 184 | } 185 | start_response = DummyStartResponse() 186 | with self.assertLogs(level="WARNING") as cm: 187 | response = router(environ, start_response) 188 | 189 | self.assertEqual(start_response.status, "400 Bad Request") 190 | self.assertEqual( 191 | json.loads(response[0]), 192 | [ 193 | { 194 | "exception": "MissingRequiredParameter", 195 | "message": "Missing required query parameter: bar", 196 | "field": "bar", 197 | } 198 | ], 199 | ) 200 | self.assertEqual( 201 | cm.output, 202 | ["WARNING:pyramid_openapi3:Missing required query parameter: bar"], 203 | ) 204 | 205 | def test_response_validation_error(self) -> None: 206 | """Test View raises ResponseValidationError. 207 | 208 | Example view raises an undefined response code. 209 | The response validation tween should catch this as response validation error, 210 | and return an error 500. 211 | """ 212 | from pyramid.httpexceptions import HTTPPreconditionFailed 213 | 214 | self._add_view(lambda *arg: HTTPPreconditionFailed()) 215 | 216 | # run request through router 217 | router = Router(self.config.registry) 218 | environ = { 219 | "wsgi.url_scheme": "http", 220 | "SERVER_NAME": "localhost", 221 | "SERVER_PORT": "8080", 222 | "REQUEST_METHOD": "GET", 223 | "PATH_INFO": "/foo", 224 | "HTTP_ACCEPT": "application/json", 225 | "QUERY_STRING": "bar=1", 226 | } 227 | start_response = DummyStartResponse() 228 | with self.assertLogs(level="ERROR") as cm: 229 | response = router(environ, start_response) 230 | self.assertEqual(start_response.status, "500 Internal Server Error") 231 | self.assertEqual( 232 | json.loads(response[0]), 233 | [ 234 | { 235 | "exception": "ResponseNotFound", 236 | "message": "Unknown response http status: 412", 237 | } 238 | ], 239 | ) 240 | self.assertEqual( 241 | cm.output, ["ERROR:pyramid_openapi3:Unknown response http status: 412"] 242 | ) 243 | 244 | def test_nonapi_view(self) -> None: 245 | """Test View without openapi validation.""" 246 | self._add_view(openapi=False) 247 | # run request through router 248 | router = Router(self.config.registry) 249 | environ = { 250 | "wsgi.url_scheme": "http", 251 | "SERVER_NAME": "localhost", 252 | "SERVER_PORT": "8080", 253 | "REQUEST_METHOD": "GET", 254 | "PATH_INFO": "/foo", 255 | } 256 | start_response = DummyStartResponse() 257 | response = router(environ, start_response) 258 | self.assertEqual(start_response.status, "200 OK") 259 | self.assertIn(b"foo", b"".join(response)) 260 | 261 | def test_nonapi_view_raises_AttributeError(self) -> None: 262 | """Test non-openapi view that accesses request.openapi_validated.""" 263 | 264 | def should_raise_error(request: Request) -> None: 265 | request.openapi_validated 266 | 267 | self._add_view(openapi=False, view_func=should_raise_error) 268 | # run request through router 269 | router = Router(self.config.registry) 270 | environ = { 271 | "wsgi.url_scheme": "http", 272 | "SERVER_NAME": "localhost", 273 | "SERVER_PORT": "8080", 274 | "REQUEST_METHOD": "GET", 275 | "PATH_INFO": "/foo", 276 | } 277 | start_response = DummyStartResponse() 278 | with self.assertRaises(AttributeError) as cm: 279 | router(environ, start_response) 280 | 281 | self.assertEqual( 282 | str(cm.exception), 283 | "Cannot do openapi request validation on a view marked with openapi=False", 284 | ) 285 | 286 | def test_request_validation_disabled(self) -> None: 287 | """Test View with request validation disabled.""" 288 | self._add_view(lambda *arg: {"test": "correct"}) 289 | 290 | # by default validation is enabled 291 | router = Router(self.config.registry) 292 | environ = { 293 | "wsgi.url_scheme": "http", 294 | "SERVER_NAME": "localhost", 295 | "SERVER_PORT": "8080", 296 | "REQUEST_METHOD": "GET", 297 | "PATH_INFO": "/foo", 298 | "HTTP_ACCEPT": "application/json", 299 | "QUERY_STRING": "bad=parameter", 300 | } 301 | start_response = DummyStartResponse() 302 | response = router(environ, start_response) 303 | self.assertEqual(start_response.status, "400 Bad Request") 304 | 305 | # now let's disable it 306 | self.config.registry.settings["pyramid_openapi3.enable_request_validation"] = ( 307 | False 308 | ) 309 | start_response = DummyStartResponse() 310 | response = router(environ, start_response) 311 | self.assertEqual(start_response.status, "200 OK") 312 | self.assertEqual(json.loads(response[0]), {"test": "correct"}) 313 | 314 | def test_response_validation_disabled(self) -> None: 315 | """Test View with response validation disabled.""" 316 | self._add_view(lambda *arg: "not-valid") 317 | 318 | # by default validation is enabled 319 | router = Router(self.config.registry) 320 | environ = { 321 | "wsgi.url_scheme": "http", 322 | "SERVER_NAME": "localhost", 323 | "SERVER_PORT": "8080", 324 | "REQUEST_METHOD": "GET", 325 | "PATH_INFO": "/foo", 326 | "HTTP_ACCEPT": "application/json", 327 | "QUERY_STRING": "bar=1", 328 | } 329 | start_response = DummyStartResponse() 330 | response = router(environ, start_response) 331 | self.assertEqual(start_response.status, "500 Internal Server Error") 332 | 333 | # now let's disable it 334 | self.config.registry.settings["pyramid_openapi3.enable_response_validation"] = ( 335 | False 336 | ) 337 | start_response = DummyStartResponse() 338 | response = router(environ, start_response) 339 | self.assertEqual(start_response.status, "200 OK") 340 | self.assertEqual(json.loads(response[0]), "not-valid") 341 | 342 | def test_request_validation_disabled_response_validation_enabled(self) -> None: 343 | """Test response validation still works if request validation is disabled.""" 344 | self._add_view(lambda *arg: "not-valid") 345 | 346 | self.config.registry.settings["pyramid_openapi3.enable_request_validation"] = ( 347 | False 348 | ) 349 | 350 | # by default validation is enabled 351 | router = Router(self.config.registry) 352 | environ = { 353 | "wsgi.url_scheme": "http", 354 | "SERVER_NAME": "localhost", 355 | "SERVER_PORT": "8080", 356 | "REQUEST_METHOD": "GET", 357 | "PATH_INFO": "/foo", 358 | "HTTP_ACCEPT": "application/json", 359 | "QUERY_STRING": "bar=1", 360 | } 361 | start_response = DummyStartResponse() 362 | router(environ, start_response) 363 | self.assertEqual(start_response.status, "500 Internal Server Error") 364 | 365 | 366 | class TestServerRequestValidation(RequestValidationBase): # noqa: D101 367 | 368 | openapi_spec = ( 369 | b"openapi: '3.1.0'\n" 370 | b"info:\n" 371 | b" version: '1.0.0'\n" 372 | b" title: Foo API\n" 373 | b"servers:\n" 374 | b" - url: /prefix/v1\n" 375 | b" - url: http://example.com/prefix\n" 376 | b"paths:\n" 377 | b" /foo:\n" 378 | b" get:\n" 379 | b" responses:\n" 380 | b" 200:\n" 381 | b" description: A foo\n" 382 | b" content:\n" 383 | b" application/json:\n" 384 | b" schema:\n" 385 | b" type: object\n" 386 | b" properties:\n" 387 | b" test:\n" 388 | b" type: string\n" 389 | b" 400:\n" 390 | b" description: Bad Request\n" 391 | ) 392 | 393 | def test_server_validation_works_with_script_name(self) -> None: 394 | """Expect to find a match for http://localhost:8080/prefix/v1/foo.""" 395 | self._add_view(lambda *arg: {"test": "correct"}) 396 | # run request through router 397 | router = Router(self.config.registry) 398 | environ = { 399 | "wsgi.url_scheme": "http", 400 | "SERVER_NAME": "localhost", 401 | "SERVER_PORT": "8080", 402 | "REQUEST_METHOD": "GET", 403 | "SCRIPT_NAME": "/prefix/v1", 404 | "PATH_INFO": "/foo", 405 | "HTTP_ACCEPT": "application/json", 406 | } 407 | start_response = DummyStartResponse() 408 | response = router(environ, start_response) 409 | 410 | self.assertEqual(start_response.status, "200 OK") 411 | self.assertEqual(json.loads(response[0]), {"test": "correct"}) 412 | 413 | def test_server_validation_works_with_script_name_and_hostname(self) -> None: 414 | """Expect to find a match for http://example.com/prefix/foo.""" 415 | self._add_view(lambda *arg: {"test": "correct"}) 416 | # run request through router 417 | router = Router(self.config.registry) 418 | environ = { 419 | "wsgi.url_scheme": "http", 420 | "SERVER_NAME": "localhost", 421 | "SERVER_PORT": "8080", 422 | "REQUEST_METHOD": "GET", 423 | "SCRIPT_NAME": "/prefix", 424 | "PATH_INFO": "/foo", 425 | "HTTP_HOST": "example.com", 426 | "HTTP_ACCEPT": "application/json", 427 | } 428 | start_response = DummyStartResponse() 429 | response = router(environ, start_response) 430 | 431 | self.assertEqual(start_response.status, "200 OK") 432 | self.assertEqual(json.loads(response[0]), {"test": "correct"}) 433 | 434 | def test_server_validation_fails_with_bad_hostname(self) -> None: 435 | """Expect to fail for /prefix/v2/foo.""" 436 | self._add_view() 437 | # run request through router 438 | router = Router(self.config.registry) 439 | environ = { 440 | "wsgi.url_scheme": "http", 441 | "SERVER_NAME": "localhost", 442 | "SERVER_PORT": "8080", 443 | "REQUEST_METHOD": "GET", 444 | "SCRIPT_NAME": "/prefix/v2", 445 | "PATH_INFO": "/foo", 446 | "HTTP_HOST": "example.com", 447 | "HTTP_ACCEPT": "application/json", 448 | } 449 | start_response = DummyStartResponse() 450 | with self.assertLogs(level="ERROR") as cm: 451 | response = router(environ, start_response) 452 | self.assertEqual(start_response.status, "500 Internal Server Error") 453 | self.assertEqual( 454 | json.loads(response[0]), 455 | [ 456 | { 457 | "exception": "ServerNotFound", 458 | "message": "Server not found for http://example.com/prefix/v2/foo", 459 | } 460 | ], 461 | ) 462 | self.assertEqual( 463 | cm.output, 464 | [ 465 | "ERROR:pyramid_openapi3:Server not found for http://example.com/prefix/v2/foo" 466 | ], 467 | ) 468 | 469 | 470 | class TestImproperAPISpecValidation(RequestValidationBase): # noqa: D101 471 | 472 | openapi_spec = ( 473 | b'openapi: "3.1.0"\n' 474 | b"info:\n" 475 | b' version: "1.0.0"\n' 476 | b" title: Foo API\n" 477 | b"paths:\n" 478 | b" /foo:\n" 479 | b" get:\n" 480 | b" parameters:\n" 481 | b" - name: bar\n" 482 | b" in: query\n" 483 | b" required: true\n" 484 | b" schema:\n" 485 | b" type: integer\n" 486 | b" responses:\n" 487 | b" 200:\n" 488 | b" description: A foo\n" 489 | ) 490 | 491 | def test_request_validation_error_causes_response_validation_error(self) -> None: 492 | """Tests fallback for when request validation is disallowed by the spec. 493 | 494 | When a request fails validation an exception is raised which causes 495 | a 400 error to be raised to the end user specifying what the problem was. 496 | If the API spec disallows 400 errors, this will be transformed into a 497 | 500 error with a response validation message about incorrect types. 498 | 499 | We should also raise a warning in this case, so developers are alerted to 500 | such problems. 501 | """ 502 | self._add_view() 503 | # run request through router 504 | router = Router(self.config.registry) 505 | environ = { 506 | "wsgi.url_scheme": "http", 507 | "SERVER_PROTOCOL": "HTTP/1.1", 508 | "SERVER_NAME": "localhost", 509 | "SERVER_PORT": "8080", 510 | "REQUEST_METHOD": "GET", 511 | "PATH_INFO": "/foo", 512 | "HTTP_ACCEPT": "application/json", 513 | "QUERY_STRING": "unknown=1", 514 | } 515 | start_response = DummyStartResponse() 516 | with self.assertLogs(level="ERROR") as cm: 517 | with warnings.catch_warnings(record=True) as cw: 518 | response = router(environ, start_response) 519 | self.assertEqual(len(cw), 1) 520 | self.assertEqual( 521 | str(cw[0].message), 522 | 'Discarding 400 Bad Request validation error with body [{"exception":"MissingRequiredParameter","message":"Missing required query parameter: bar","field":"bar"}] as it is not a valid response for GET to /foo (foo)', 523 | ) 524 | self.assertEqual( 525 | cm.output, ["ERROR:pyramid_openapi3:Unknown response http status: 400"] 526 | ) 527 | 528 | self.assertEqual(start_response.status, "500 Internal Server Error") 529 | self.assertEqual( 530 | json.loads(response[0]), 531 | [ 532 | { 533 | "exception": "ResponseNotFound", 534 | "message": "Unknown response http status: 400", 535 | } 536 | ], 537 | ) 538 | -------------------------------------------------------------------------------- /pyramid_openapi3/tests/test_wrappers.py: -------------------------------------------------------------------------------- 1 | """Tests for the wrappers.py module.""" 2 | 3 | from dataclasses import dataclass 4 | from openapi_core.validation.request.datatypes import RequestParameters 5 | from pyramid.request import Request 6 | from pyramid.testing import DummyRequest 7 | from pyramid_openapi3.wrappers import PyramidOpenAPIRequest 8 | from pyramid_openapi3.wrappers import PyramidOpenAPIResponse 9 | 10 | 11 | @dataclass 12 | class DummyRoute: # noqa: D101 13 | name: str 14 | pattern: str 15 | 16 | 17 | def test_mapped_values_request() -> None: 18 | """Test that values are correctly mapped from pyramid's Request.""" 19 | 20 | pyramid_request = DummyRequest(path="/foo") 21 | pyramid_request.matched_route = DummyRoute(name="foo", pattern="/foo") 22 | pyramid_request.matchdict["foo"] = "bar" 23 | pyramid_request.headers["X-Foo"] = "Bar" 24 | pyramid_request.cookies["tasty-foo"] = "tasty-bar" 25 | pyramid_request.content_type = "text/html" 26 | 27 | assert pyramid_request.application_url == "http://example.com" 28 | assert pyramid_request.path_info == "/foo" 29 | assert pyramid_request.method == "GET" 30 | 31 | openapi_request = PyramidOpenAPIRequest(pyramid_request) 32 | 33 | assert openapi_request.parameters == RequestParameters( 34 | path={"foo": "bar"}, 35 | query={}, 36 | header={"X-Foo": "Bar"}, 37 | cookie={"tasty-foo": "tasty-bar"}, 38 | ) 39 | assert openapi_request.host_url == "http://example.com" 40 | assert openapi_request.path == "/foo" 41 | assert openapi_request.path_pattern == "/foo" 42 | assert openapi_request.method == "get" 43 | assert openapi_request.body == "" 44 | assert openapi_request.mimetype == "text/html" 45 | assert openapi_request.content_type == "text/html" 46 | 47 | 48 | def test_relative_app_request() -> None: 49 | """Test that values are correctly mapped from pyramid's Request.""" 50 | 51 | pyramid_request = Request.blank("/foo", base_url="http://example.com/subpath") 52 | pyramid_request.matched_route = DummyRoute(name="foo", pattern="/foo") 53 | pyramid_request.matchdict = {"foo": "bar"} 54 | pyramid_request.headers["X-Foo"] = "Bar" 55 | pyramid_request.cookies["tasty-foo"] = "tasty-bar" 56 | pyramid_request.content_type = "text/html" 57 | 58 | assert pyramid_request.host_url == "http://example.com" 59 | assert pyramid_request.path_info == "/foo" 60 | assert pyramid_request.method == "GET" 61 | 62 | openapi_request = PyramidOpenAPIRequest(pyramid_request) 63 | 64 | assert openapi_request.parameters == RequestParameters( 65 | path={"foo": "bar"}, 66 | query={}, 67 | header=pyramid_request.headers, 68 | cookie={"tasty-foo": "tasty-bar"}, 69 | ) 70 | assert openapi_request.host_url == "http://example.com" 71 | assert openapi_request.path == "/subpath/foo" 72 | assert openapi_request.path_pattern == "/subpath/foo" 73 | assert openapi_request.method == "get" 74 | assert openapi_request.body == b"" 75 | assert openapi_request.mimetype == "text/html" 76 | assert openapi_request.content_type == "text/html" 77 | 78 | 79 | def test_no_matched_route() -> None: 80 | """Test path_pattern when no route is matched.""" 81 | pyramid_request = DummyRequest(path="/foo") 82 | pyramid_request.matched_route = None 83 | pyramid_request.content_type = "text/html" 84 | 85 | openapi_request = PyramidOpenAPIRequest(pyramid_request) 86 | assert openapi_request.host_url == "http://example.com" 87 | assert openapi_request.path == "/foo" 88 | assert openapi_request.path_pattern == "/foo" 89 | 90 | 91 | def test_mapped_values_response() -> None: 92 | """Test that values are correctly mapped from pyramid's Response.""" 93 | pyramid_request = DummyRequest() 94 | 95 | assert pyramid_request.response.body == b"" 96 | assert pyramid_request.response.status_code == 200 97 | assert pyramid_request.response.content_type == "text/html" 98 | 99 | openapi_response = PyramidOpenAPIResponse(pyramid_request.response) 100 | 101 | assert openapi_response.data == b"" 102 | assert openapi_response.status_code == 200 103 | assert openapi_response.mimetype == "text/html" 104 | assert openapi_response.content_type == "text/html" 105 | assert openapi_response.headers == pyramid_request.response.headers 106 | -------------------------------------------------------------------------------- /pyramid_openapi3/tween.py: -------------------------------------------------------------------------------- 1 | """A tween to validate openapi responses.""" 2 | 3 | from .exceptions import ImproperAPISpecificationWarning 4 | from .exceptions import ResponseValidationError 5 | from .wrappers import PyramidOpenAPIRequest 6 | from .wrappers import PyramidOpenAPIResponse 7 | from pyramid.registry import Registry 8 | from pyramid.request import Request 9 | from pyramid.response import Response 10 | 11 | import typing as t 12 | import warnings 13 | 14 | 15 | def response_tween_factory( 16 | handler: t.Callable[[Request], Response], registry: Registry 17 | ) -> t.Callable[[Request], Response]: 18 | """Create response validation tween. 19 | 20 | This tween should run after pyramid exception renderer view, so that 21 | final response status and content_type are known and can be validated. 22 | 23 | The only problem here is, that when response validation fails, we have 24 | to return some exception response, with an unknown content type. 25 | The advantage is, that these are server errors, and if 500 errors are 26 | only possible due to response validation errors we don't need to document 27 | them in the openapi spec file. 28 | """ 29 | 30 | def excview_tween(request: Request) -> Response: 31 | try: 32 | response = handler(request) 33 | if not request.environ.get("pyramid_openapi3.validate_response"): 34 | return response 35 | 36 | # validate response 37 | openapi_request = PyramidOpenAPIRequest(request) 38 | openapi_response = PyramidOpenAPIResponse(response) 39 | settings_key = "pyramid_openapi3" 40 | gsettings = settings = request.registry.settings[settings_key] 41 | if "routes" in gsettings: 42 | settings_key = gsettings["routes"][request.matched_route.name] 43 | settings = request.registry.settings[settings_key] 44 | result = settings["response_validator"].unmarshal( 45 | request=openapi_request, response=openapi_response 46 | ) 47 | request_validated = request.environ.get("pyramid_openapi3.validate_request") 48 | if result.errors: 49 | if request_validated and request.openapi_validated.errors: 50 | warnings.warn_explicit( 51 | ImproperAPISpecificationWarning( 52 | "Discarding {response.status} validation error with body " 53 | "{response.text} as it is not a valid response for " 54 | "{request.method} to {request.path} ({route.name})".format( 55 | response=response, 56 | request=request, 57 | route=request.matched_route, 58 | ) 59 | ), 60 | None, 61 | registry.settings[settings_key]["filepath"], 62 | 0, 63 | ) 64 | raise ResponseValidationError(response=response, errors=result.errors) 65 | 66 | # If there is no exception view, we also see request validation errors here 67 | except ResponseValidationError: 68 | return request.invoke_exception_view(reraise=True) 69 | return response 70 | 71 | return excview_tween 72 | -------------------------------------------------------------------------------- /pyramid_openapi3/wrappers.py: -------------------------------------------------------------------------------- 1 | """Wrap Pyramid's Request and Response.""" 2 | 3 | from openapi_core.validation.request.datatypes import RequestParameters 4 | from pyramid.request import Request 5 | from pyramid.response import Response 6 | 7 | import typing as t 8 | 9 | # Ignore D401 for @property methods as imperative mood docstrings do not make sense 10 | # for them 11 | 12 | 13 | class PyramidOpenAPIRequest: 14 | """Map Pyramid Request attributes to what openapi expects.""" 15 | 16 | def __init__(self, request: Request) -> None: 17 | self.request = request 18 | 19 | self.parameters = RequestParameters( 20 | path=self.request.matchdict, 21 | query=self.request.GET, 22 | header=self.request.headers, 23 | cookie=self.request.cookies, 24 | ) 25 | 26 | @property 27 | def host_url(self) -> str: 28 | """Url with scheme and host. Example: https://localhost:8000.""" # noqa D401 29 | return self.request.host_url 30 | 31 | @property 32 | def path(self) -> str: 33 | """The request path.""" # noqa D401 34 | return self.request.path 35 | 36 | @property 37 | def path_pattern(self) -> str: 38 | """The matched url with path pattern.""" # noqa D401 39 | path_pattern = ( 40 | self.request.matched_route.pattern 41 | if self.request.matched_route 42 | else self.request.path_info 43 | ) 44 | return self.request.script_name + path_pattern 45 | 46 | @property 47 | def method(self) -> str: 48 | """The request method, as lowercase string.""" # noqa D401 49 | return self.request.method.lower() 50 | 51 | @property 52 | def body(self) -> t.Optional[t.Union[bytes, str, t.Dict]]: 53 | """The request body.""" # noqa D401 54 | return self.request.body 55 | 56 | @property 57 | def content_type(self) -> str: 58 | """The content type of the request.""" # noqa D401 59 | if "multipart/form-data" == self.request.content_type: 60 | # Pyramid does not include boundary in request.content_type, but 61 | # openapi-core needs it to parse the request body. 62 | return self.request.headers.environ.get( 63 | "CONTENT_TYPE", "multipart/form-data" 64 | ) 65 | return self.request.content_type 66 | 67 | @property 68 | def mimetype(self) -> str: 69 | """The content type of the request.""" # noqa D401 70 | return self.request.content_type 71 | 72 | 73 | class PyramidOpenAPIResponse: 74 | """Map Pyramid Response attributes to what openapi expects.""" 75 | 76 | def __init__(self, response: Response) -> None: 77 | self.response = response 78 | 79 | @property 80 | def data(self) -> bytes: 81 | """The response body.""" # noqa D401 82 | return self.response.body 83 | 84 | @property 85 | def status_code(self) -> int: 86 | """The status code as integer.""" # noqa D401 87 | return self.response.status_code 88 | 89 | @property 90 | def content_type(self) -> str: 91 | """The content type of the response.""" # noqa D401 92 | return self.response.content_type 93 | 94 | @property 95 | def mimetype(self) -> str: 96 | """The content type of the response.""" # noqa D401 97 | return self.response.content_type 98 | 99 | @property 100 | def headers(self) -> t.Mapping[str, t.Any]: 101 | """The response headers.""" # noqa D401 102 | return self.response.headers 103 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | nixpkgs = builtins.fetchTarball { 3 | # https://github.com/NixOS/nixpkgs/tree/nixos-23.11 on 2024-03-07 4 | url = "https://github.com/nixos/nixpkgs/archive/f945939fd679284d736112d3d5410eb867f3b31c.tar.gz"; 5 | sha256 = "06da1wf4w752spsm16kkckfhxx5m09lwcs8931gwh76yvclq7257"; 6 | }; 7 | poetry2nixsrc = builtins.fetchTarball { 8 | # https://github.com/nix-community/poetry2nix/commits/master on 2024-03-07 9 | url = "https://github.com/nix-community/poetry2nix/archive/3c92540611f42d3fb2d0d084a6c694cd6544b609.tar.gz"; 10 | sha256 = "1jfrangw0xb5b8sdkimc550p3m98zhpb1fayahnr7crg74as4qyq"; 11 | }; 12 | 13 | pkgs = import nixpkgs { }; 14 | poetry2nix = import poetry2nixsrc { 15 | inherit pkgs; 16 | # inherit (pkgs) poetry; 17 | }; 18 | 19 | commonPoetryArgs = { 20 | projectDir = ./.; 21 | preferWheels = true; 22 | editablePackageSources = { 23 | pyramid_openapi3 = ./.; 24 | }; 25 | overrides = poetry2nix.overrides.withDefaults (self: super: { }); 26 | }; 27 | 28 | devEnv_312 = poetry2nix.mkPoetryEnv (commonPoetryArgs // { 29 | python = pkgs.python312; 30 | }); 31 | 32 | devEnv_310 = poetry2nix.mkPoetryEnv (commonPoetryArgs // { 33 | python = pkgs.python310; 34 | pyproject = ./py310/pyproject.toml; 35 | poetrylock = ./py310/poetry.lock; 36 | }); 37 | 38 | in 39 | 40 | pkgs.mkShell { 41 | name = "dev-shell"; 42 | 43 | buildInputs = with pkgs; [ 44 | devEnv_312 45 | devEnv_310 46 | poetry 47 | gitAndTools.pre-commit 48 | nixpkgs-fmt 49 | ]; 50 | } 51 | --------------------------------------------------------------------------------