├── tests ├── __init__.py ├── acceptance │ ├── __init__.py │ ├── app │ │ ├── config.ini │ │ └── __init__.py │ ├── invalid_file_test.py │ ├── recursive_app_test.py │ ├── config_ini_test.py │ ├── format_test.py │ ├── prefer_20_routes_test.py │ ├── yaml_test.py │ ├── relative_ref_test.py │ ├── api_test.py │ ├── response20_test.py │ ├── request_test.py │ └── response_test.py ├── sample_schemas │ ├── external_refs │ │ ├── A.json │ │ └── swagger.json │ ├── bad_app │ │ ├── api_docs.json │ │ ├── bad_sample.json │ │ └── swagger.json │ ├── prefer_20_routes_app │ │ ├── api_docs.json │ │ ├── swagger.json │ │ └── other_sample.json │ ├── missing_api_declaration │ │ └── api_docs.json │ ├── relative_ref │ │ ├── parameters │ │ │ └── common.json │ │ ├── swagger.json │ │ ├── responses │ │ │ └── common.json │ │ ├── paths │ │ │ └── common.json │ │ └── dereferenced_swagger.json │ ├── recursive_app │ │ ├── external │ │ │ ├── external.json │ │ │ └── swagger.json │ │ └── internal │ │ │ └── swagger.json │ ├── good_app │ │ ├── post_endpoint_with_optional_body.json │ │ ├── api_docs.json │ │ ├── no_models.json │ │ ├── echo_date.json │ │ ├── other_sample.json │ │ ├── sample.json │ │ └── swagger.json │ ├── missing_resource_listing │ │ └── bad_sample.json │ ├── nested_defns │ │ └── swagger.yaml │ ├── yaml_app │ │ ├── swagger.yaml │ │ └── defs.yaml │ └── user_format │ │ └── swagger.json ├── conftest.py ├── spec_test.py ├── load_schema_test.py ├── model_test.py ├── includeme_test.py ├── api_test.py └── ingest_test.py ├── .deactivate.sh ├── .activate.sh ├── .landscape.yaml ├── setup.cfg ├── requirements-dev.txt ├── MANIFEST.in ├── .gitignore ├── .github └── workflows │ ├── ci.yaml │ └── pypi.yaml ├── Makefile ├── docs ├── external_resources.rst ├── glossary.rst ├── what_is_swagger.rst ├── index.rst ├── migrating_to_swagger_20.rst ├── conf.py └── changelog.rst ├── .appveyor.yml ├── pyramid_swagger ├── __about__.py ├── spec.py ├── exceptions.py ├── renderer.py ├── __init__.py ├── model.py ├── ingest.py ├── load_schema.py └── api.py ├── tox.ini ├── .pre-commit-config.yaml ├── setup.py ├── LICENSE └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/acceptance/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.deactivate.sh: -------------------------------------------------------------------------------- 1 | deactivate 2 | -------------------------------------------------------------------------------- /.activate.sh: -------------------------------------------------------------------------------- 1 | source venv/bin/activate 2 | -------------------------------------------------------------------------------- /.landscape.yaml: -------------------------------------------------------------------------------- 1 | strictness: high 2 | ignore-paths: 3 | - docs 4 | - setup.py 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [wheel] 5 | universal = True 6 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | bravado 3 | coverage 4 | mock 5 | ordereddict 6 | pre-commit 7 | pytest>=3 8 | tox 9 | webtest 10 | -------------------------------------------------------------------------------- /tests/sample_schemas/external_refs/A.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "A": { 4 | "type": "object" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | # Required by python 2.6 even though we include these in our setup.py 3 | include pyramid_swagger/swagger_spec_schemas/v1.2/* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .idea/ 3 | *pyc 4 | *swp 5 | docs/_build 6 | docs/build 7 | build/ 8 | dist/ 9 | .coverage 10 | .tox 11 | .ropeproject 12 | pyramid_swagger.egg-info 13 | tests/__pycache__ 14 | venv/ 15 | .pytest_cache/ 16 | -------------------------------------------------------------------------------- /tests/sample_schemas/bad_app/api_docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "swaggerVersion": "1.2", 3 | "apis": [ 4 | { 5 | "path": "/bad_sample", 6 | "description": "An invalid api declaration." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tests/sample_schemas/prefer_20_routes_app/api_docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "swaggerVersion": "1.2", 3 | "apis": [ 4 | { 5 | "path": "/other_sample", 6 | "description": "Two heads are better than one." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tests/sample_schemas/missing_api_declaration/api_docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "swaggerVersion": "1.2", 3 | "apis": [ 4 | { 5 | "path": "/missing", 6 | "description": "An api declaration that does not exist." 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tests/sample_schemas/relative_ref/parameters/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "path_arg": { 3 | "in": "path", 4 | "name": "path_arg", 5 | "required": true, 6 | "type": "string", 7 | "enum": ["path_arg1", "path_arg2"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/sample_schemas/external_refs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "dumb spec" 6 | }, 7 | "paths": { 8 | "/blah": { 9 | "get": { 10 | "responses": { 11 | "200": { 12 | "description": "blah", 13 | "schema": { 14 | "$ref": "A.json" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: build 3 | on: push 4 | jobs: 5 | tox: 6 | runs-on: ubuntu-22.04 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | tox: 11 | - '3.8' 12 | - '3.10' 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - run: pip install tox 19 | - run: tox -e py 20 | -------------------------------------------------------------------------------- /tests/sample_schemas/recursive_app/external/external.json: -------------------------------------------------------------------------------- 1 | { 2 | "widget": { 3 | "description": "A widget which may have multiple generations of child widgets", 4 | "properties": { 5 | "name": { 6 | "type": "string", 7 | "minLength": 1, 8 | "maxLength": 50 9 | }, 10 | "children": { 11 | "type": "array", 12 | "items": { 13 | "$ref":"#/widget" 14 | } 15 | } 16 | }, 17 | "type": "object" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean docs install-hooks test 2 | 3 | all: venv install-hooks 4 | 5 | test: install-hooks 6 | tox 7 | 8 | install-hooks: venv 9 | venv/bin/pre-commit install -f --install-hooks 10 | 11 | venv: setup.py requirements-dev.txt 12 | virtualenv venv 13 | venv/bin/pip install -r requirements-dev.txt 14 | 15 | docs: 16 | tox -e docs 17 | mkdir -p docs/build 18 | cp -r docs/_build/html docs/build/html 19 | 20 | clean: 21 | find . -type f -iname "*.py[co]" -delete 22 | rm -fr *.egg-info/ 23 | rm -fr .tox/ 24 | rm -fr venv/ 25 | -------------------------------------------------------------------------------- /docs/external_resources.rst: -------------------------------------------------------------------------------- 1 | External resources 2 | =========================================== 3 | 4 | There are a variety of external resources you will find useful when documenting 5 | your API with Swagger. 6 | 7 | * `Interactive Spec Editor `_ 8 | * `Swagger 1.2 Specification `_ 9 | * `Swagger 2.0 Specification `_ 10 | * `"Pet Store" example API `_ 11 | -------------------------------------------------------------------------------- /tests/sample_schemas/relative_ref/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Title was not specified", 5 | "version": "0.1" 6 | }, 7 | "produces": ["application/json"], 8 | "paths": { 9 | "/no_models": { 10 | "$ref": "paths/common.json#/no_models" 11 | }, 12 | "/sample/{path_arg}/resource": { 13 | "$ref": "paths/common.json#/sample_resource" 14 | } 15 | }, 16 | "host": "localhost:9999", 17 | "schemes": [ 18 | "http" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/sample_schemas/relative_ref/responses/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "200": { 3 | "description": "Return a standard_response", 4 | "schema": { 5 | "type": "object", 6 | "required": [ 7 | "raw_response", 8 | "logging_info" 9 | ], 10 | "additionalProperties": false, 11 | "properties": { 12 | "raw_response": { 13 | "type": "string" 14 | }, 15 | "logging_info": { 16 | "type": "object" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/acceptance/app/config.ini: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = call:tests.acceptance.app:main 3 | pyramid_swagger.enable_request_validation = true 4 | pyramid_swagger.enable_response_validation = false 5 | pyramid_swagger.enable_path_validation = true 6 | pyramid_swagger.exclude_routes = /undefined/first 7 | /undefined/second 8 | pyramid_swagger.prefer_20_routes = /sample 9 | pyramid_swagger.schema_directory = tests/sample_schemas/good_app 10 | pyramid_swagger.swagger_versions = 1.2 2.0 11 | pyramid_swagger.response_validation_exclude_routes = /exclude_response/first 12 | /exclude_response/second 13 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | version: '{build}' 2 | image: Visual Studio 2017 3 | 4 | environment: 5 | matrix: 6 | # Available python versions and their locations on https://www.appveyor.com/docs/build-environment/#python 7 | - PYTHON: C:\Python27-x64 8 | TOXENV: py27 9 | - PYTHON: C:\Python27-x64 10 | TOXENV: py27-pyramid15 11 | - PYTHON: C:\Python35-x64 12 | TOXENV: py35 13 | - PYTHON: C:\Python36-x64 14 | TOXENV: py36 15 | 16 | build: off 17 | 18 | install: 19 | - cmd: SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH% 20 | - cmd: pip install tox 21 | 22 | before_test: 23 | - cmd: python --version 24 | - cmd: pip --version 25 | - cmd: tox --version 26 | 27 | test_script: 28 | - cmd: tox 29 | -------------------------------------------------------------------------------- /pyramid_swagger/__about__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import division 4 | from __future__ import print_function 5 | 6 | 7 | __all__ = [ 8 | "__title__", "__summary__", "__uri__", "__version__", "__author__", 9 | "__email__", "__license__", "__copyright__", 10 | ] 11 | 12 | __title__ = "pyramid_swagger" 13 | __summary__ = "Swagger tools for use in pyramid webapps" 14 | __uri__ = "https://github.com/striglia/pyramid_swagger" 15 | 16 | __version__ = "2.9.0" 17 | 18 | __author__ = "Scott Triglia" 19 | __email__ = "scott.triglia@gmail.com" 20 | 21 | __license__ = "BSD 3-clause" 22 | __copyright__ = "Copyright 2014 Scott Triglia" 23 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | import pytest 7 | 8 | 9 | @pytest.yield_fixture(autouse=True) 10 | def _set_current_directory_to_git_repo_root(): 11 | """ 12 | During tests execution make sure that current working directory 13 | is set accordingly to git repository root. 14 | It will allow to run tests with relative file import with the 15 | same reference path. 16 | """ 17 | repo_root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 18 | curdir = os.getcwd() 19 | try: 20 | os.chdir(repo_root_path) 21 | yield 22 | finally: 23 | os.chdir(curdir) 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py310 3 | 4 | [testenv] 5 | skip_install = True 6 | deps = -rrequirements-dev.txt 7 | pyramid15: pyramid<=1.5.4 8 | 9 | commands = 10 | coverage run --source=pyramid_swagger/ --omit=pyramid_swagger/__about__.py -m pytest --capture=no --strict {posargs:tests/} 11 | coverage report -m 12 | pre-commit run --all-files 13 | 14 | [flake8] 15 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,*.egg,docs/conf.py 16 | max_line_length = 120 17 | 18 | [testenv:docs] 19 | deps = 20 | sphinx 21 | sphinx-rtd-theme 22 | commands = 23 | sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html 24 | sphinx-build -W -b linkcheck docs docs/_build/html 25 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/post_endpoint_with_optional_body.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/post_endpoint_with_optional_body", 5 | "apis": [ 6 | { 7 | "path": "/post_endpoint_with_optional_body", 8 | "operations": [ 9 | { 10 | "method": "POST", 11 | "nickname": "post_endpoint_with_optional_body", 12 | "type": "integer", 13 | "parameters": [ 14 | { 15 | "name": "body", 16 | "paramType": "body", 17 | "required": false, 18 | "type": "object" 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | ], 25 | "models": { 26 | "object": { 27 | "id": "object", 28 | "properties": { 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/sample_schemas/missing_resource_listing/bad_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/sample", 5 | "apis": [ 6 | { 7 | "path": "/required_body", 8 | "description": "requires a body", 9 | "operations": [ 10 | { 11 | "method": "GET", 12 | "type": "object", 13 | "parameters": [ 14 | { 15 | "paramType": "query", 16 | "name": "content", 17 | "required": true 18 | } 19 | ], 20 | "summary": "Tests required body parameters" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tests/sample_schemas/relative_ref/paths/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "no_models": { 3 | "get": { 4 | "responses": { 5 | "200": { 6 | "$ref": "../responses/common.json#/200" 7 | } 8 | }, 9 | "description": "", 10 | "operationId": "no_models_get" 11 | } 12 | }, 13 | "sample_resource": { 14 | "get": { 15 | "responses": { 16 | "200": { 17 | "$ref": "../responses/common.json#/200" 18 | } 19 | }, 20 | "description": "", 21 | "operationId": "standard", 22 | "parameters": [ 23 | { 24 | "$ref": "../parameters/common.json#/path_arg" 25 | } 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: pypi 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.10' 16 | - run: python -m pip install --upgrade setuptools pip 'tox<4' virtualenv 17 | - run: tox -e py310 18 | pypi: 19 | needs: test 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: pypi 23 | url: https://pypi.org/p/pyramid-swagger 24 | permissions: 25 | id-token: write 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-python@v4 29 | with: 30 | python-version: '3.10' 31 | - run: python setup.py sdist 32 | - uses: pypa/gh-action-pypi-publish@v1.8.10 33 | -------------------------------------------------------------------------------- /tests/sample_schemas/bad_app/bad_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/sample", 5 | "apis": [ 6 | { 7 | "path": "/required_body", 8 | "description": "requires a body", 9 | "operations": [ 10 | { 11 | "method": "GET", 12 | "type": "object", 13 | "parameters": [ 14 | { 15 | "paramType": "query", 16 | "name": "content", 17 | "required": true, 18 | "type": "string" 19 | } 20 | ], 21 | "summary": "Tests required body parameters" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/api_docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "swaggerVersion": "1.2", 3 | "apis": [ 4 | { 5 | "path": "/no_models", 6 | "description": "As it says." 7 | }, 8 | { 9 | "path": "/sample", 10 | "description": "Sample valid api declaration." 11 | }, 12 | { 13 | "path": "/other_sample", 14 | "description": "Two heads are better than one." 15 | }, 16 | { 17 | "path": "/echo_date", 18 | "description": "Echoes the input body in the response. Endpoint used for verifying PyramidSwaggerRendererFactory" 19 | }, 20 | { 21 | "path": "/post_endpoint_with_optional_body", 22 | "description": "Returns the request body length. Used to verify that optional body parameters are well handled" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/no_models.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/no_models", 5 | "apis": [ 6 | { 7 | "path": "/no_models", 8 | "operations": [ 9 | { 10 | "method": "GET", 11 | "nickname": "no_models_get", 12 | "type": "string", 13 | "parameters": [] 14 | } 15 | ] 16 | }, 17 | { 18 | "path": "/throw_400", 19 | "operations": [ 20 | { 21 | "method": "GET", 22 | "nickname": "throw_400", 23 | "type": "string", 24 | "parameters": [] 25 | } 26 | ] 27 | } 28 | ], 29 | "models": { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | Glossary of Terms 2 | =========================================== 3 | 4 | Nothing more than some common vocabulary for you to absorb. 5 | 6 | swagger api-docs (often swagger api) 7 | The preferred term for the resource listing and associated api declarations. This is so-named to avoid confusion with the Swagger Specification and the actual implementation of your service. 8 | 9 | resource listing 10 | The top-level declaration of the various Swagger resources your service exposes. Each resource must have an associated api declaration. 11 | 12 | api declaration 13 | The description of each endpoint a particular Swagger service provides, with complete input and output declared. 14 | 15 | swagger spec 16 | The formal specification of a valid swagger api. The current public version is 2.0 and hosted on `wordnik's Github `_. 17 | -------------------------------------------------------------------------------- /docs/what_is_swagger.rst: -------------------------------------------------------------------------------- 1 | What is Swagger? 2 | ================= 3 | 4 | Basic working knowledge of Swagger is a prerequisite for having pyramid_swagger 5 | make sense as a library. 6 | 7 | `Swagger ` is a specification format for describing HTTP services, with a particular focus on RESTful APIs. The schema you write will describe your API comprehensively. 8 | 9 | The benefit of going through the work of writing a Swagger schema for API is you then get access to a great number of tools which work off this spec. The Swagger website has an entire page devoted to `community tools which consume this schema `. In fact, you'll notice that pyramid_swagger is listed as one of these. 10 | 11 | In short, your Swagger schema simply describes your API. For a more in-depth introduction, try `the official Swagger introduction article`. 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.2.1 4 | hooks: 5 | - id: check-json 6 | - id: check-yaml 7 | - id: debug-statements 8 | - id: end-of-file-fixer 9 | - id: fix-encoding-pragma 10 | - id: name-tests-test 11 | - id: trailing-whitespace 12 | - repo: https://github.com/pre-commit/mirrors-autopep8 13 | rev: v1.4.4 14 | hooks: 15 | - id: autopep8 16 | args: 17 | - -i 18 | - --ignore=E309,E501 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 3.7.6 21 | hooks: 22 | - id: flake8 23 | args: 24 | - --ignore=W503 25 | exclude: ^docs 26 | - repo: https://github.com/asottile/reorder_python_imports 27 | rev: v1.4.0 28 | hooks: 29 | - id: reorder-python-imports 30 | args: 31 | - --add-import 32 | - from __future__ import absolute_import 33 | -------------------------------------------------------------------------------- /tests/sample_schemas/nested_defns/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: / 2 | 3 | definitions: 4 | A: 5 | type: object 6 | B: 7 | allOf: 8 | - properties: 9 | items: 10 | $ref: '#/definitions/A' 11 | C: 12 | properties: 13 | foobar: 14 | $ref: '#/definitions/B' 15 | D: 16 | properties: 17 | foobar: 18 | $ref: '#/definitions/C' 19 | E: 20 | properties: 21 | foobar: 22 | $ref: '#/definitions/D' 23 | F: 24 | properties: 25 | foobar: 26 | $ref: '#/definitions/E' 27 | G: 28 | properties: 29 | foobar: 30 | $ref: '#/definitions/F' 31 | info: 32 | title: A pointless spec 33 | version: 42.42.42 34 | 35 | paths: {} 36 | 37 | produces: 38 | - application/json 39 | 40 | schemes: 41 | - https 42 | 43 | swagger: '2.0' 44 | -------------------------------------------------------------------------------- /tests/sample_schemas/yaml_app/swagger.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | swagger: "2.0" 3 | info: 4 | title: "Title was not specified" 5 | version: "0.1" 6 | host: "localhost:9999" 7 | schemes: 8 | - "http" 9 | produces: 10 | - "application/json" 11 | paths: 12 | "/sample/{path_arg}/resource": 13 | get: 14 | responses: 15 | "200": 16 | description: "Return a standard_response" 17 | schema: 18 | "$ref": "defs.yaml#/definitions/standard_response" 19 | description: "" 20 | operationId: "standard" 21 | parameters: 22 | - in: "path" 23 | name: "path_arg" 24 | required: true 25 | type: "string" 26 | enum: 27 | - "path_arg1" 28 | - "path_arg2" 29 | - in: "query" 30 | name: "required_arg" 31 | required: true 32 | type: "string" 33 | format: "base64" 34 | - in: "query" 35 | name: "optional_arg" 36 | required: false 37 | type: "string" 38 | post: 39 | $ref: defs.yaml#/operations/post 40 | -------------------------------------------------------------------------------- /tests/sample_schemas/recursive_app/external/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Bug demo", 5 | "description": "Recursive object definitions cause problems", 6 | "version": "0.1.0" 7 | }, 8 | "definitions": { 9 | "widget": { 10 | "$ref": "external.json#/widget" 11 | } 12 | }, 13 | "paths": { 14 | "/resources/widget/{code}" : { 15 | "parameters" : [ 16 | { 17 | "name": "code", 18 | "description": "The CODE value for a widget", 19 | "in": "path", 20 | "type": "string", 21 | "required": true 22 | } 23 | ], 24 | "get": { 25 | "summary": "View a single widget", 26 | "produces": ["application/json"], 27 | "parameters": [], 28 | "responses": { 29 | "200": { 30 | "description":"Widget local content (without parents or descendents)", 31 | "schema": {"$ref":"external.json#/widget"} 32 | }, 33 | "404": {"description": "The widget does not exist"} 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/spec_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | import pytest 7 | import simplejson 8 | from jsonschema.exceptions import ValidationError 9 | 10 | from pyramid_swagger.spec import API_DOCS_FILENAME 11 | from pyramid_swagger.spec import validate_swagger_schema 12 | 13 | 14 | def test_success_for_good_app(): 15 | dir_path = 'tests/sample_schemas/good_app/'.replace('/', os.path.sep) 16 | with open(os.path.join(dir_path, API_DOCS_FILENAME)) as f: 17 | resource_listing = simplejson.load(f) 18 | validate_swagger_schema(dir_path, resource_listing) 19 | 20 | 21 | def test_proper_error_on_missing_api_declaration(): 22 | with pytest.raises(ValidationError) as exc: 23 | dir_path = 'tests/sample_schemas/missing_api_declaration/'.replace('/', os.path.sep) 24 | with open(os.path.join(dir_path, API_DOCS_FILENAME)) as f: 25 | resource_listing = simplejson.load(f) 26 | validate_swagger_schema(dir_path, resource_listing) 27 | 28 | assert os.path.basename(dir_path) in str(exc.value) 29 | assert os.path.basename('missing.json') in str(exc.value) 30 | -------------------------------------------------------------------------------- /tests/sample_schemas/yaml_app/defs.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | standard_response: 3 | type: "object" 4 | required: 5 | - "raw_response" 6 | - "logging_info" 7 | additionalProperties: false 8 | properties: 9 | raw_response: 10 | type: "string" 11 | logging_info: 12 | type: "object" 13 | body_model: 14 | type: "object" 15 | required: 16 | - "foo" 17 | additionalProperties: false 18 | properties: 19 | foo: 20 | type: "string" 21 | bar: 22 | type: "string" 23 | array_content_model: 24 | required: 25 | - "enum_value" 26 | properties: 27 | enum_value: 28 | type: "string" 29 | enum: 30 | - "good_enum_value" 31 | 32 | operations: 33 | post: 34 | parameters: 35 | - name: path_arg 36 | in: path 37 | type: string 38 | required: true 39 | - name: request 40 | in: body 41 | required: true 42 | schema: 43 | $ref: "#/definitions/body_model" 44 | responses: 45 | default: 46 | description: test response 47 | schema: 48 | $ref: "#/definitions/standard_response" 49 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pyramid_swagger documentation master file, created by 2 | sphinx-quickstart on Mon May 12 13:42:31 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to pyramid_swagger's documentation! 7 | =========================================== 8 | 9 | This project offers convenient tools for using `Swagger `_ to define and validate 10 | your interfaces in a `Pyramid `_ webapp. 11 | 12 | **You must supply** a working Pyramid application, and a Swagger schema describing your application's interface. In return, pyramid_swagger will provide: 13 | 14 | * Request and response validation 15 | 16 | * Swagger spec validation 17 | 18 | * Automatically serving the swagger schema to interested clients (e.g. `Swagger UI `_) 19 | 20 | pyramid_swagger works for both the 1.2 and 2.0 Swagger specifications, although users are strongly encouraged to use 2.0 going forward. 21 | 22 | Contents: 23 | 24 | .. toctree:: 25 | :maxdepth: 1 26 | 27 | what_is_swagger 28 | quickstart 29 | changelog 30 | configuration 31 | migrating_to_swagger_20 32 | external_resources 33 | glossary 34 | -------------------------------------------------------------------------------- /tests/acceptance/invalid_file_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import base64 5 | 6 | import pytest 7 | from webtest import TestApp as App 8 | 9 | from pyramid_swagger.tween import SwaggerFormat 10 | from tests.acceptance.app import main 11 | 12 | 13 | @pytest.fixture 14 | def settings(): 15 | dir_path = 'tests/sample_schemas/' 16 | return { 17 | 'pyramid_swagger.schema_file': 'swagger.txt', 18 | 'pyramid_swagger.schema_directory': dir_path, 19 | 'pyramid_swagger.enable_request_validation': True, 20 | 'pyramid_swagger.enable_swagger_spec_validation': True, 21 | } 22 | 23 | 24 | @pytest.fixture 25 | def yaml_app(): 26 | return SwaggerFormat(format='base64', 27 | to_wire=base64.b64encode, 28 | to_python=base64.b64decode, 29 | validate=base64.b64decode, 30 | description='base64') 31 | 32 | 33 | def test_invalid_file_extension(settings, yaml_app): 34 | """Fixture for setting up a Swagger 2.0 version of the test testapp.""" 35 | settings['pyramid_swagger.swagger_versions'] = ['2.0'] 36 | settings['pyramid_swagger.user_formats'] = [yaml_app] 37 | 38 | with pytest.raises(Exception): 39 | App(main({}, **settings)) 40 | -------------------------------------------------------------------------------- /pyramid_swagger/spec.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Methods to help validate a given JSON document against the Swagger Spec. 4 | """ 5 | from __future__ import absolute_import 6 | 7 | import os 8 | 9 | import swagger_spec_validator 10 | from jsonschema.exceptions import ValidationError 11 | from six.moves.urllib import parse as urlparse 12 | from six.moves.urllib.request import pathname2url 13 | 14 | from pyramid_swagger.exceptions import wrap_exception 15 | 16 | API_DOCS_FILENAME = 'api_docs.json' 17 | 18 | 19 | @wrap_exception(ValidationError) 20 | def validate_swagger_schema(schema_dir, resource_listing): 21 | """Validate the structure of Swagger schemas against the spec. 22 | 23 | **Valid only for Swagger v1.2 spec** 24 | 25 | Note: It is possible that resource_listing is not present in 26 | the schema_dir. The path is passed in the call so that ssv 27 | can fetch the api-declaration files from the path. 28 | 29 | :param resource_listing: Swagger Spec v1.2 resource listing 30 | :type resource_listing: dict 31 | :param schema_dir: A path to Swagger spec directory 32 | :type schema_dir: string 33 | :raises: :py:class:`swagger_spec_validator.SwaggerValidationError` 34 | """ 35 | schema_filepath = os.path.join(schema_dir, API_DOCS_FILENAME) 36 | swagger_spec_validator.validator12.validate_spec( 37 | resource_listing, 38 | urlparse.urljoin('file:', pathname2url(os.path.abspath(schema_filepath))), 39 | ) 40 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/echo_date.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/echo_date", 5 | "apis": [ 6 | { 7 | "path": "/echo_date", 8 | "operations": [ 9 | { 10 | "method": "POST", 11 | "nickname": "echo_date", 12 | "type": "string", 13 | "parameters": [ 14 | { 15 | "name": "body", 16 | "paramType": "body", 17 | "type": "object_with_formats" 18 | } 19 | ] 20 | } 21 | ] 22 | }, 23 | { 24 | "path": "/echo_date_json_renderer", 25 | "operations": [ 26 | { 27 | "description": "This endpoint is used in tests/acceptance/request_test.py and requires to be identical to /echo_date endpoint", 28 | "method": "POST", 29 | "nickname": "echo_date", 30 | "parameters": [ 31 | { 32 | "name": "body", 33 | "paramType": "body", 34 | "type": "object_with_formats" 35 | } 36 | ], 37 | "type": "string" 38 | } 39 | ] 40 | } 41 | ], 42 | "models": { 43 | "object_with_formats": { 44 | "id": "object_with_formats", 45 | "properties": { 46 | "date": { 47 | "type": "string", 48 | "format": "date" 49 | } 50 | }, 51 | "required": [ 52 | "date" 53 | ] 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/sample_schemas/recursive_app/internal/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Bug demo", 5 | "description": "Recursive object definitions cause problems", 6 | "version": "0.1.0" 7 | }, 8 | "definitions": { 9 | "widget": { 10 | "description": "A widget which may have multiple generations of child widgets", 11 | "properties": { 12 | "name": { 13 | "type": "string", 14 | "minLength": 1, 15 | "maxLength": 50 16 | }, 17 | "children": { 18 | "type": "array", 19 | "items": { 20 | "$ref":"#/definitions/widget" 21 | } 22 | } 23 | }, 24 | "type": "object" 25 | } 26 | }, 27 | "paths": { 28 | "/resources/widget/{code}" : { 29 | "parameters" : [ 30 | { 31 | "name": "code", 32 | "description": "The CODE value for a widget", 33 | "in": "path", 34 | "type": "string", 35 | "required": true 36 | } 37 | ], 38 | "get": { 39 | "summary": "View a single widget", 40 | "produces": ["application/json"], 41 | "parameters": [], 42 | "responses": { 43 | "200": { 44 | "description":"Widget local content (without parents or descendents)", 45 | "schema": {"$ref":"#/definitions/widget"} 46 | }, 47 | "404": {"description": "The widget does not exist"} 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import io 5 | import os 6 | 7 | from setuptools import find_packages 8 | from setuptools import setup 9 | 10 | 11 | base_dir = os.path.dirname(__file__) 12 | about = {} 13 | with open(os.path.join(base_dir, "pyramid_swagger", "__about__.py")) as f: 14 | exec(f.read(), about) 15 | 16 | with io.open(os.path.join(base_dir, "README.rst"), encoding='utf-8') as f: 17 | long_description = f.read() 18 | 19 | setup( 20 | name=about['__title__'], 21 | version=about['__version__'], 22 | 23 | description=about['__summary__'], 24 | long_description=long_description, 25 | license=about['__license__'], 26 | url=about["__uri__"], 27 | 28 | author=about['__author__'], 29 | author_email=about['__email__'], 30 | 31 | classifiers=[ 32 | 'Development Status :: 5 - Production/Stable', 33 | 34 | 'Intended Audience :: Developers', 35 | 'Topic :: Software Development :: Libraries :: Python Modules', 36 | 37 | 'Programming Language :: Python :: 3.8', 38 | 'Programming Language :: Python :: 3.10', 39 | 40 | 'License :: OSI Approved :: BSD License', 41 | ], 42 | keywords='pyramid swagger validation', 43 | packages=find_packages(exclude=["contrib", "docs", "tests*"]), 44 | include_package_data=True, 45 | install_requires=[ 46 | 'bravado-core >= 4.8.4', 47 | 'jsonschema >= 3.0.0', 48 | 'pyramid', 49 | 'simplejson', 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Scott Triglia 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /tests/acceptance/recursive_app_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import json 5 | 6 | import pytest 7 | import yaml 8 | from six import BytesIO 9 | from webtest import TestApp as App 10 | 11 | from tests.acceptance.app import main 12 | 13 | 14 | DESERIALIZERS = { 15 | 'json': lambda r: json.loads(r.body.decode('utf-8')), 16 | 'yaml': lambda r: yaml.safe_load(BytesIO(r.body)), 17 | } 18 | 19 | 20 | @pytest.fixture 21 | def settings(): 22 | dir_path = 'tests/sample_schemas/recursive_app/external/' 23 | return { 24 | 'pyramid_swagger.schema_directory': dir_path, 25 | 'pyramid_swagger.enable_request_validation': True, 26 | 'pyramid_swagger.enable_swagger_spec_validation': True, 27 | 'pyramid_swagger.swagger_versions': ['2.0'] 28 | } 29 | 30 | 31 | @pytest.fixture 32 | def test_app_deref(settings): 33 | """Fixture for setting up a Swagger 2.0 version of the test test_app 34 | test app serves swagger schemas with refs dereferenced.""" 35 | settings['pyramid_swagger.dereference_served_schema'] = True 36 | return App(main({}, **settings)) 37 | 38 | 39 | @pytest.mark.parametrize('schema_format', ['json']) 40 | def test_dereferenced_swagger_schema_bravado_client( 41 | schema_format, 42 | test_app_deref, 43 | ): 44 | from bravado.client import SwaggerClient 45 | 46 | response = test_app_deref.get('/swagger.{0}'.format(schema_format)) 47 | deserializer = DESERIALIZERS[schema_format] 48 | specs = deserializer(response) 49 | 50 | SwaggerClient.from_spec(specs) 51 | -------------------------------------------------------------------------------- /tests/acceptance/config_ini_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os.path 5 | 6 | import pytest 7 | from pyramid.paster import get_appsettings 8 | from webtest import TestApp as App 9 | 10 | from pyramid_swagger.tween import get_swagger_versions 11 | from pyramid_swagger.tween import load_settings 12 | from tests.acceptance.app import main 13 | 14 | 15 | @pytest.fixture 16 | def ini_app(): 17 | settings = get_appsettings(os.path.join(os.path.dirname(__file__), 'app', 'config.ini'), name='main') 18 | # Swagger 1.2 tests are broken. Swagger 1.2 is deprecated and thus we have no plans to fix these tests, 19 | # so removing them here. 20 | settings["pyramid_swagger.swagger_versions"] = "2.0" 21 | return App(main({}, **settings)) 22 | 23 | 24 | def test_load_ini_settings(ini_app): 25 | registry = ini_app.app.registry 26 | settings = load_settings(registry) 27 | 28 | # Make sure these settings are booleans 29 | assert settings.validate_request is True 30 | assert settings.validate_response is False 31 | assert settings.validate_path is True 32 | assert settings.exclude_routes == {'/undefined/first', '/undefined/second'} 33 | assert settings.prefer_20_routes == {'/sample'} 34 | assert settings.response_validation_exclude_routes == {'/exclude_response/first', '/exclude_response/second'} 35 | 36 | 37 | def test_get_swagger_versions(ini_app): 38 | settings = ini_app.app.registry.settings 39 | swagger_versions = get_swagger_versions(settings) 40 | # Swagger 1.2 tests are broken. Swagger 1.2 is deprecated and thus we have no plans to fix these tests, 41 | # so removing them here. 42 | assert swagger_versions == {'2.0'} 43 | -------------------------------------------------------------------------------- /pyramid_swagger/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import sys 5 | 6 | import six 7 | from pyramid.httpexceptions import HTTPBadRequest 8 | from pyramid.httpexceptions import HTTPInternalServerError 9 | from pyramid.httpexceptions import HTTPNotFound 10 | from pyramid.httpexceptions import HTTPUnauthorized 11 | 12 | 13 | class RequestValidationError(HTTPBadRequest): 14 | def __init__(self, *args, **kwargs): 15 | self.child = kwargs.pop('child', None) 16 | super(RequestValidationError, self).__init__(*args, **kwargs) 17 | 18 | 19 | class RequestAuthenticationError(HTTPUnauthorized): 20 | def __init__(self, *args, **kwargs): 21 | self.child = kwargs.pop('child', None) 22 | super(RequestAuthenticationError, self).__init__(*args, **kwargs) 23 | 24 | 25 | class PathNotFoundError(HTTPNotFound): 26 | def __init__(self, *args, **kwargs): 27 | self.child = kwargs.pop('child', None) 28 | super(PathNotFoundError, self).__init__(*args, **kwargs) 29 | 30 | 31 | class ResponseValidationError(HTTPInternalServerError): 32 | def __init__(self, *args, **kwargs): 33 | self.child = kwargs.pop('child', None) 34 | super(ResponseValidationError, self).__init__(*args, **kwargs) 35 | 36 | 37 | def wrap_exception(exception_class): 38 | def generic_exception(method): 39 | def wrapper(*args, **kwargs): 40 | try: 41 | method(*args, **kwargs) 42 | except Exception as e: 43 | six.reraise( 44 | exception_class, 45 | exception_class(str(e)), 46 | sys.exc_info()[2]) 47 | return wrapper 48 | return generic_exception 49 | -------------------------------------------------------------------------------- /tests/sample_schemas/prefer_20_routes_app/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Title was not specified", 5 | "version": "0.1" 6 | }, 7 | "produces": ["application/json"], 8 | "paths": { 9 | "/sample/{path_arg}/resource": { 10 | "get": { 11 | "responses": { 12 | "200": { 13 | "description": "Return a standard_response", 14 | "schema": { 15 | "$ref": "#/definitions/standard_response" 16 | } 17 | } 18 | }, 19 | "description": "", 20 | "operationId": "standard", 21 | "parameters": [ 22 | { 23 | "in": "path", 24 | "name": "path_arg", 25 | "required": true, 26 | "type": "string", 27 | "enum": ["path_arg1", "path_arg2"] 28 | }, 29 | { 30 | "in": "query", 31 | "name": "required_arg", 32 | "required": true, 33 | "type": "string" 34 | }, 35 | { 36 | "in": "query", 37 | "name": "optional_arg", 38 | "required": false, 39 | "type": "string" 40 | } 41 | ] 42 | } 43 | } 44 | }, 45 | "host": "localhost:9999", 46 | "schemes": [ 47 | "http" 48 | ], 49 | "definitions": { 50 | "standard_response": { 51 | "type": "object", 52 | "required": [ 53 | "raw_response", 54 | "logging_info" 55 | ], 56 | "additionalProperties": false, 57 | "properties": { 58 | "raw_response": { 59 | "type": "string" 60 | }, 61 | "logging_info": { 62 | "type": "object" 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/acceptance/format_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import base64 5 | 6 | import pytest 7 | from webtest import TestApp as App 8 | 9 | from pyramid_swagger.tween import SwaggerFormat 10 | from tests.acceptance.app import main 11 | 12 | 13 | @pytest.fixture 14 | def settings(): 15 | dir_path = 'tests/sample_schemas/user_format/' 16 | return { 17 | 'pyramid_swagger.schema_directory': dir_path, 18 | 'pyramid_swagger.enable_request_validation': True, 19 | 'pyramid_swagger.enable_swagger_spec_validation': True, 20 | } 21 | 22 | 23 | @pytest.fixture 24 | def user_format(): 25 | return SwaggerFormat(format='base64', 26 | to_wire=base64.b64encode, 27 | to_python=base64.b64decode, 28 | validate=base64.b64decode, 29 | description='base64') 30 | 31 | 32 | @pytest.fixture 33 | def testapp_with_base64(settings, user_format): 34 | """Fixture for setting up a Swagger 2.0 version of the test testapp.""" 35 | settings['pyramid_swagger.swagger_versions'] = ['2.0'] 36 | settings['pyramid_swagger.user_formats'] = [user_format] 37 | return App(main({}, **settings)) 38 | 39 | 40 | def test_user_format_happy_case(testapp_with_base64): 41 | response = testapp_with_base64.get('/sample/path_arg1/resource', 42 | params={'required_arg': 'MQ=='},) 43 | assert response.status_code == 200 44 | 45 | 46 | def test_user_format_failure_case(testapp_with_base64): 47 | # 'MQ' is not a valid base64 encoded string. 48 | with pytest.raises(Exception): 49 | testapp_with_base64.get('/sample/path_arg1/resource', 50 | params={'required_arg': 'MQ'},) 51 | -------------------------------------------------------------------------------- /tests/load_schema_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import mock 5 | import pytest 6 | 7 | from pyramid_swagger import load_schema 8 | 9 | 10 | @pytest.fixture 11 | def mock_validator(): 12 | return mock.Mock(spec=['validate', 'is_type']) 13 | 14 | 15 | def test_required_validator_bool_is_missing(): 16 | schema = {'paramType': 'body', 'name': 'body'} 17 | errors = list(load_schema.required_validator(None, True, {}, schema)) 18 | assert len(errors) == 1 19 | assert 'body is required' in str(errors[0]) 20 | 21 | 22 | def test_required_validator_bool_is_present(): 23 | schema = {'paramType': 'body', 'name': 'body'} 24 | inst = {'foo': 1} 25 | errors = list(load_schema.required_validator(None, True, inst, schema)) 26 | assert len(errors) == 0 27 | 28 | 29 | def test_required_validator_bool_not_required(): 30 | schema = {'paramType': 'body', 'name': 'body'} 31 | errors = list(load_schema.required_validator(None, False, {}, schema)) 32 | assert len(errors) == 0 33 | 34 | 35 | def test_required_validator_list(mock_validator): 36 | required = ['one', 'two'] 37 | errors = list(load_schema.required_validator( 38 | mock_validator, required, {}, {})) 39 | assert len(errors) == 2 40 | 41 | 42 | def test_type_validator_skips_File(): 43 | schema = {'paramType': 'form', 'type': 'File'} 44 | errors = list(load_schema.type_validator(None, "File", '', schema)) 45 | assert len(errors) == 0 46 | 47 | 48 | @mock.patch('pyramid_swagger.load_schema._draft3_type_validator') 49 | def test_type_validator_calls_draft3_type_validator_when_not_File(mock_type_draft3): 50 | schema = {'paramType': 'form', 'type': 'number'} 51 | list(load_schema.type_validator(None, "number", 99, schema)) 52 | assert mock_type_draft3.call_count == 1 53 | -------------------------------------------------------------------------------- /pyramid_swagger/renderer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This model contains the main factory of renderers to use while dealing with Swagger 2.0 endpoints. 4 | """ 5 | from __future__ import absolute_import 6 | 7 | from functools import partial 8 | 9 | from bravado_core.exception import MatchingResponseNotFound 10 | from bravado_core.exception import SwaggerMappingError 11 | from bravado_core.marshal import marshal_schema_object 12 | from bravado_core.response import get_response_spec 13 | from pyramid.renderers import JSON 14 | 15 | 16 | class PyramidSwaggerRendererFactory(object): 17 | def __init__(self, renderer_factory=JSON()): 18 | self.renderer_factory = renderer_factory 19 | 20 | def _marshal_object(self, request, response_object): 21 | # operation attribute is injected by validator_tween in case the endpoint is served by Swagger 2.0 specs 22 | operation = getattr(request, 'operation', None) 23 | 24 | if not operation: 25 | # If the request is not served by Swagger2.0 endpoint _marshal_object is NO_OP 26 | return response_object 27 | 28 | try: 29 | response_spec = get_response_spec( 30 | status_code=request.response.status_code, 31 | op=request.operation, 32 | ) 33 | return marshal_schema_object( 34 | swagger_spec=request.registry.settings['pyramid_swagger.schema20'], 35 | schema_object_spec=response_spec['schema'], 36 | value=response_object, 37 | ) 38 | except (MatchingResponseNotFound, SwaggerMappingError, KeyError): 39 | # marshaling process failed 40 | return response_object 41 | 42 | def _render(self, external_renderer, value, system): 43 | value = self._marshal_object(system['request'], value) 44 | return external_renderer(value, system) 45 | 46 | def __call__(self, info): 47 | return partial(self._render, self.renderer_factory(info)) 48 | -------------------------------------------------------------------------------- /tests/acceptance/prefer_20_routes_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import pytest 5 | from webtest import TestApp as App 6 | 7 | from tests.acceptance.app import main 8 | 9 | 10 | @pytest.fixture 11 | def settings(): 12 | dir_path = 'tests/sample_schemas/prefer_20_routes_app/' 13 | return { 14 | 'pyramid_swagger.schema_directory': dir_path, 15 | 'pyramid_swagger.enable_request_validation': True, 16 | 'pyramid_swagger.enable_swagger_spec_validation': True, 17 | # Swagger 1.2 tests are broken. Swagger 1.2 is deprecated and thus we have no plans to fix these tests, 18 | # so removing them here. 19 | 'pyramid_swagger.swagger_versions': '2.0', 20 | } 21 | 22 | 23 | @pytest.fixture 24 | def test_app_with_no_prefer_conf(settings): 25 | """Fixture for setting up a Swagger 2.0 version with no 26 | `prefer_20_routes` option added to settings.""" 27 | return App(main({}, **settings)) 28 | 29 | 30 | @pytest.fixture 31 | def test_app_with_prefer_conf(settings): 32 | """Fixture for setting up a Swagger 2.0 version with a particular route 33 | `standard` added to `prefer_20_routes` option.""" 34 | settings['pyramid_swagger.prefer_20_routes'] = ['standard'] 35 | return App(main({}, **settings)) 36 | 37 | 38 | def test_failure_with_no_prefer_config_case(test_app_with_no_prefer_conf): 39 | """The second get call should fail as it is not covered in v2.0 spec 40 | """ 41 | response = test_app_with_no_prefer_conf.get('/sample/path_arg1/resource', 42 | params={'required_arg': 'a'},) 43 | assert response.status_code == 200 44 | with pytest.raises(Exception): 45 | test_app_with_no_prefer_conf.get( 46 | '/sample/nonstring/1/1.1/true', params={},) 47 | 48 | 49 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 50 | def test_success_with_prefer_config_case(test_app_with_prefer_conf): 51 | response = test_app_with_prefer_conf.get('/sample/path_arg1/resource', 52 | params={'required_arg': 'a'},) 53 | assert response.status_code == 200 54 | response = test_app_with_prefer_conf.get( 55 | '/sample/nonstring/1/1.1/true', params={},) 56 | assert response.status_code == 200 57 | -------------------------------------------------------------------------------- /pyramid_swagger/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Import this module to add the validation tween to your pyramid app. 4 | """ 5 | from __future__ import absolute_import 6 | 7 | import pyramid 8 | 9 | from pyramid_swagger.api import build_swagger_20_swagger_schema_views 10 | from pyramid_swagger.api import register_api_doc_endpoints 11 | from pyramid_swagger.ingest import get_swagger_schema 12 | from pyramid_swagger.ingest import get_swagger_spec 13 | from pyramid_swagger.renderer import PyramidSwaggerRendererFactory 14 | from pyramid_swagger.tween import get_swagger_versions 15 | from pyramid_swagger.tween import SWAGGER_12 16 | from pyramid_swagger.tween import SWAGGER_20 17 | 18 | 19 | def includeme(config): 20 | """ 21 | :type config: :class:`pyramid.config.Configurator` 22 | """ 23 | settings = config.registry.settings 24 | swagger_versions = get_swagger_versions(settings) 25 | 26 | # for rendering /swagger.yaml 27 | config.add_renderer( 28 | 'yaml', 'pyramid_swagger.api.YamlRendererFactory', 29 | ) 30 | 31 | # Add the SwaggerSchema to settings to make it available to the validation 32 | # tween and `register_api_doc_endpoints` 33 | settings['pyramid_swagger.schema12'] = None 34 | settings['pyramid_swagger.schema20'] = None 35 | 36 | # Store under two keys so that 1.2 and 2.0 can co-exist. 37 | if SWAGGER_12 in swagger_versions: 38 | settings['pyramid_swagger.schema12'] = get_swagger_schema(settings) 39 | 40 | if SWAGGER_20 in swagger_versions: 41 | settings['pyramid_swagger.schema20'] = get_swagger_spec(settings) 42 | 43 | config.add_tween( 44 | "pyramid_swagger.tween.validation_tween_factory", 45 | under=pyramid.tweens.EXCVIEW 46 | ) 47 | 48 | config.add_renderer('pyramid_swagger', PyramidSwaggerRendererFactory()) 49 | 50 | if settings.get('pyramid_swagger.enable_api_doc_views', True): 51 | if SWAGGER_12 in swagger_versions: 52 | register_api_doc_endpoints( 53 | config, 54 | settings['pyramid_swagger.schema12'].get_api_doc_endpoints()) 55 | 56 | if SWAGGER_20 in swagger_versions: 57 | register_api_doc_endpoints( 58 | config, 59 | build_swagger_20_swagger_schema_views(config), 60 | base_path=settings.get('pyramid_swagger.base_path_api_docs', '')) 61 | -------------------------------------------------------------------------------- /tests/sample_schemas/relative_ref/dereferenced_swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost:9999", 3 | "info": { 4 | "title": "Title was not specified", 5 | "version": "0.1" 6 | }, 7 | "parameters": { 8 | "lfile:parameters..common.json|..path_arg": { 9 | "enum": [ 10 | "path_arg1", 11 | "path_arg2" 12 | ], 13 | "in": "path", 14 | "name": "path_arg", 15 | "required": true, 16 | "type": "string" 17 | } 18 | }, 19 | "paths": { 20 | "/no_models": { 21 | "get": { 22 | "description": "", 23 | "operationId": "no_models_get", 24 | "responses": { 25 | "200": { 26 | "$ref": "#/responses/lfile:responses..common.json|..200" 27 | } 28 | } 29 | } 30 | }, 31 | "/sample/{path_arg}/resource": { 32 | "get": { 33 | "description": "", 34 | "operationId": "standard", 35 | "parameters": [ 36 | { 37 | "$ref": "#/parameters/lfile:parameters..common.json|..path_arg" 38 | } 39 | ], 40 | "responses": { 41 | "200": { 42 | "$ref": "#/responses/lfile:responses..common.json|..200" 43 | } 44 | } 45 | } 46 | } 47 | }, 48 | "produces": [ 49 | "application/json" 50 | ], 51 | "responses": { 52 | "lfile:responses..common.json|..200": { 53 | "description": "Return a standard_response", 54 | "schema": { 55 | "additionalProperties": false, 56 | "properties": { 57 | "logging_info": { 58 | "type": "object" 59 | }, 60 | "raw_response": { 61 | "type": "string" 62 | } 63 | }, 64 | "required": [ 65 | "raw_response", 66 | "logging_info" 67 | ], 68 | "type": "object" 69 | } 70 | } 71 | }, 72 | "schemes": [ 73 | "http" 74 | ], 75 | "swagger": "2.0" 76 | } 77 | -------------------------------------------------------------------------------- /tests/sample_schemas/user_format/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Title was not specified", 5 | "version": "0.1" 6 | }, 7 | "produces": ["application/json"], 8 | "paths": { 9 | "/sample/{path_arg}/resource": { 10 | "get": { 11 | "responses": { 12 | "200": { 13 | "description": "Return a standard_response", 14 | "schema": { 15 | "$ref": "#/definitions/standard_response" 16 | } 17 | } 18 | }, 19 | "description": "", 20 | "operationId": "standard", 21 | "parameters": [ 22 | { 23 | "in": "path", 24 | "name": "path_arg", 25 | "required": true, 26 | "type": "string", 27 | "enum": ["path_arg1", "path_arg2"] 28 | }, 29 | { 30 | "in": "query", 31 | "name": "required_arg", 32 | "required": true, 33 | "type": "string", 34 | "format": "base64" 35 | }, 36 | { 37 | "in": "query", 38 | "name": "optional_arg", 39 | "required": false, 40 | "type": "string" 41 | } 42 | ] 43 | } 44 | } 45 | }, 46 | "host": "localhost:9999", 47 | "schemes": [ 48 | "http" 49 | ], 50 | "definitions": { 51 | "standard_response": { 52 | "type": "object", 53 | "required": [ 54 | "raw_response", 55 | "logging_info" 56 | ], 57 | "additionalProperties": false, 58 | "properties": { 59 | "raw_response": { 60 | "type": "string" 61 | }, 62 | "logging_info": { 63 | "type": "object" 64 | } 65 | } 66 | }, 67 | "body_model": { 68 | "type": "object", 69 | "required": [ 70 | "foo" 71 | ], 72 | "additionalProperties": false, 73 | "properties": { 74 | "foo": { 75 | "type": "string" 76 | }, 77 | "bar": { 78 | "type": "string" 79 | } 80 | } 81 | }, 82 | "array_content_model": { 83 | "required": [ 84 | "enum_value" 85 | ], 86 | "properties": { 87 | "enum_value": { 88 | "type": "string", 89 | "enum": [ 90 | "good_enum_value" 91 | ] 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /docs/migrating_to_swagger_20.rst: -------------------------------------------------------------------------------- 1 | Migrating to Swagger 2.0 2 | ======================== 3 | 4 | So you're using pyramid_swagger with Swagger 1.2 and now it is time to upgrade to Swagger 2.0. 5 | 6 | Just set the version of Swagger to support via configuration. 7 | 8 | .. code-block:: ini 9 | 10 | [app:main] 11 | pyramid_swagger.swagger_versions = ['2.0'] 12 | 13 | If you would like to continue servicing Swagger 1.2 clients, pyramid_swagger has you covered. 14 | 15 | .. code-block:: ini 16 | 17 | [app:main] 18 | pyramid_swagger.swagger_versions = ['1.2', '2.0'] 19 | 20 | .. note:: 21 | 22 | When both versions of Swagger are supported, all requests are validated against the 2.0 version of the schema only. 23 | Make sure that your 1.2 and 2.0 schemas define an identical set of APIs. 24 | 25 | If you're not using an ini file, configuration in Python also works. 26 | 27 | .. code-block:: python 28 | 29 | def main(global_config, **settings): 30 | # ... 31 | settings['pyramid_swagger.swagger_versions'] = ['2.0'] 32 | # ...and so on with the other settings... 33 | config = Configurator(settings=settings) 34 | config.include('pyramid_swagger') 35 | 36 | Next, create a Swagger 2.0 version of your swagger schema. There are some great resources to help you with the conversion process. 37 | 38 | * `Swagger 1.2 to 2.0 Migration Guide `_ 39 | * `Swagger Converter `_ 40 | * `Swagger 2.0 Specification `_ 41 | 42 | Finally, place your Swagger 2.0 schema ``swagger.json`` file in the same directory as your Swagger 1.2 schema and you're ready to go. 43 | 44 | .. _prefer20migration: 45 | 46 | Incremental Migration 47 | --------------------- 48 | 49 | If your v1.2 spec is too large and you are looking to migrate specs incrementally, then the below 50 | config can be useful. 51 | 52 | .. code-block:: ini 53 | 54 | [app:main] 55 | pyramid_swagger.prefer_20_routes = ['route_foo'] 56 | 57 | .. note:: 58 | 59 | The above config is read only when both `['1.2', '2.0']` are present in `swagger_versions` config. If that 60 | is the case and the request's route is present in `prefer_20_routes`, ONLY then the request is served through 61 | swagger 2.0 otherwise through 1.2. The only exception is either the config is not defined at all or both of the 62 | swagger versions are not enabled, in any of these cases, v2.0 is preferred (as mentioned in above note). 63 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | :PyPI: https://pypi.python.org/pypi/pyramid_swagger 2 | :Documentation: http://pyramid-swagger.readthedocs.org/en/latest/ 3 | :Source: https://github.com/striglia/pyramid_swagger 4 | :License: Copyright © 2014 Scott Triglia under the `BSD 3-clause `_ 5 | :Build status: 6 | .. image:: https://travis-ci.org/striglia/pyramid_swagger.png?branch=master 7 | :target: https://travis-ci.org/striglia/pyramid_swagger?branch=master 8 | :alt: Travis CI 9 | .. image:: https://ci.appveyor.com/api/projects/status/ufmlmpwy1vj3yjgk/branch/master?svg=true 10 | :target: https://ci.appveyor.com/project/striglia/pyramid-swagger 11 | :alt: Appveyor (Windows CI) 12 | :Current coverage on master: 13 | .. image:: https://coveralls.io/repos/striglia/pyramid_swagger/badge.png 14 | :target: https://coveralls.io/r/striglia/pyramid_swagger 15 | :Persistent chat for questions: 16 | .. image:: https://badges.gitter.im/Join%20Chat.svg 17 | :alt: Join the chat at https://gitter.im/striglia/pyramid_swagger 18 | :target: https://gitter.im/striglia/pyramid_swagger?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 19 | 20 | 21 | pyramid_swagger 22 | =============== 23 | 24 | This project offers convenient tools for using Swagger to define and validate 25 | your interfaces in a Pyramid webapp. 26 | 27 | Full documentation is available at http://pyramid-swagger.readthedocs.org/. 28 | 29 | 30 | How to contribute 31 | ----------------- 32 | 33 | #. Fork this repository on Github: https://help.github.com/articles/fork-a-repo/ 34 | #. Clone your forked repository: https://help.github.com/articles/cloning-a-repository/ 35 | #. Make a feature branch for your changes: 36 | 37 | :: 38 | 39 | git remote add upstream https://github.com/Yelp/pyramid_swagger.git 40 | git fetch upstream 41 | git checkout upstream/master -b my-feature-branch 42 | 43 | #. Create and activate the virtual environment, this will provide you with all the 44 | libraries and tools necessary for pyramid_swagger development: 45 | 46 | :: 47 | 48 | make 49 | source .activate.sh 50 | 51 | #. Make sure the test suite works before you start: 52 | 53 | :: 54 | 55 | tox -e py38 # Note: use py310 for Python 3.10, see tox.ini for possible values 56 | 57 | #. Commit patches: http://gitref.org/basic/ 58 | #. Push to github: ``git pull && git push origin`` 59 | #. Send a pull request: https://help.github.com/articles/creating-a-pull-request/ 60 | 61 | 62 | Running a single test 63 | ********************* 64 | 65 | Make sure you have activated the virtual environment (see above). 66 | 67 | :: 68 | 69 | py.test -vvv tests/tween_test.py::test_response_properties 70 | -------------------------------------------------------------------------------- /tests/sample_schemas/prefer_20_routes_app/other_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/sample", 5 | "apis": [ 6 | { 7 | "path": "/sample/{path_arg}/resource", 8 | "operations": [ 9 | { 10 | "method": "GET", 11 | "nickname": "standard", 12 | "type": "standard_response", 13 | "parameters": [ 14 | { 15 | "paramType": "path", 16 | "name": "path_arg", 17 | "type": "string", 18 | "enum": ["path_arg1", "path_arg2"], 19 | "required": true 20 | }, 21 | { 22 | "paramType": "query", 23 | "name": "required_arg", 24 | "type": "string", 25 | "required": true 26 | } 27 | ] 28 | } 29 | ] 30 | }, 31 | { 32 | "path": "/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}", 33 | "operations": [ 34 | { 35 | "method": "GET", 36 | "nickname": "sample_nonstring", 37 | "type": "void", 38 | "parameters": [ 39 | { 40 | "paramType": "path", 41 | "name": "int_arg", 42 | "type": "integer", 43 | "required": true 44 | }, 45 | { 46 | "paramType": "path", 47 | "name": "float_arg", 48 | "type": "number", 49 | "format": "float", 50 | "required": true 51 | }, 52 | { 53 | "paramType": "path", 54 | "name": "boolean_arg", 55 | "type": "boolean", 56 | "required": true 57 | } 58 | ] 59 | } 60 | ] 61 | } 62 | ], 63 | "models": { 64 | "object": { 65 | "id": "object", 66 | "properties": { } 67 | }, 68 | "standard_response": { 69 | "id": "standard_response", 70 | "type": "object", 71 | "required": [ "raw_response", "logging_info" ], 72 | "additionalProperties": false, 73 | "properties": { 74 | "raw_response": { 75 | "type": "string" 76 | }, 77 | "logging_info": { 78 | "$ref": "object" 79 | } 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pyramid_swagger/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | The core model we use to represent the entire ingested swagger schema for this 4 | service. 5 | """ 6 | from __future__ import absolute_import 7 | 8 | import re 9 | from collections import namedtuple 10 | 11 | 12 | PyramidEndpoint = namedtuple( 13 | 'PyramidEndpoint', 14 | 'path route_name view renderer') 15 | 16 | 17 | class PathNotMatchedError(Exception): 18 | """Raised when a SwaggerSchema object is given a request it cannot match 19 | against its stored schema.""" 20 | pass 21 | 22 | 23 | class SwaggerSchema(object): 24 | """ 25 | This object contains data structures representing your Swagger schema 26 | and exposes methods for efficiently finding the relevant schemas for a 27 | Pyramid request. 28 | 29 | :param pyramid_endpoints: a list of :class:`PyramidEndpoint` which define 30 | the pyramid endpoints to create for serving the api docs 31 | :param resource_validators: a list of resolvers, one per Swagger resource 32 | :type resource_validators: list of mappings from :class:`RequestMatcher` 33 | to :class:`ValidatorMap` 34 | for every operation in the api specification. 35 | """ 36 | 37 | def __init__(self, pyramid_endpoints, resource_validators): 38 | self.pyramid_endpoints = pyramid_endpoints 39 | self.resource_validators = resource_validators 40 | 41 | def validators_for_request(self, request, **kwargs): 42 | """Takes a request and returns a validator mapping for the request. 43 | 44 | :param request: A Pyramid request to fetch schemas for 45 | :type request: :class:`pyramid.request.Request` 46 | :returns: a :class:`pyramid_swagger.load_schema.ValidatorMap` which can 47 | be used to validate `request` 48 | """ 49 | for resource_validator in self.resource_validators: 50 | for matcher, validator_map in resource_validator.items(): 51 | if matcher.matches(request): 52 | return validator_map 53 | 54 | raise PathNotMatchedError( 55 | 'Could not find the relevant path ({0}) in the Swagger schema. ' 56 | 'Perhaps you forgot to add it?'.format(request.path_info) 57 | ) 58 | 59 | def get_api_doc_endpoints(self): 60 | return self.pyramid_endpoints 61 | 62 | 63 | def partial_path_match(path1, path2, kwarg_re=r'\{.*\}'): 64 | """Validates if path1 and path2 matches, ignoring any kwargs in the string. 65 | 66 | We need this to ensure we can match Swagger patterns like: 67 | /foo/{id} 68 | against the observed pyramid path 69 | /foo/1 70 | 71 | :param path1: path of a url 72 | :type path1: string 73 | :param path2: path of a url 74 | :type path2: string 75 | :param kwarg_re: regex pattern to identify kwargs 76 | :type kwarg_re: regex string 77 | :returns: boolean 78 | """ 79 | split_p1 = path1.split('/') 80 | split_p2 = path2.split('/') 81 | pat = re.compile(kwarg_re) 82 | if len(split_p1) != len(split_p2): 83 | return False 84 | for partial_p1, partial_p2 in zip(split_p1, split_p2): 85 | if pat.match(partial_p1) or pat.match(partial_p2): 86 | continue 87 | if not partial_p1 == partial_p2: 88 | return False 89 | return True 90 | -------------------------------------------------------------------------------- /tests/model_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Unit tests for the SwaggerSchema class. 4 | """ 5 | from __future__ import absolute_import 6 | 7 | import mock 8 | import pytest 9 | 10 | from pyramid_swagger.ingest import compile_swagger_schema 11 | from pyramid_swagger.ingest import get_resource_listing 12 | from pyramid_swagger.model import partial_path_match 13 | from pyramid_swagger.model import PathNotMatchedError 14 | 15 | 16 | @pytest.fixture 17 | def schema(): 18 | schema_dir = 'tests/sample_schemas/good_app/' 19 | return compile_swagger_schema( 20 | schema_dir, 21 | get_resource_listing(schema_dir, False) 22 | ) 23 | 24 | 25 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 26 | def test_swagger_schema_for_request_different_methods(schema): 27 | """Tests that validators_for_request() checks the request 28 | method.""" 29 | # There exists a GET and POST for this endpoint. We should be able to call 30 | # either and have them pass validation. 31 | value = schema.validators_for_request( 32 | request=mock.Mock( 33 | path_info="/sample", 34 | method="GET" 35 | ), 36 | ) 37 | assert value.body.schema is None 38 | 39 | value = schema.validators_for_request( 40 | request=mock.Mock( 41 | path_info="/sample", 42 | method="POST", 43 | body={'foo': 1, 'bar': 2}, 44 | ), 45 | ) 46 | assert value.body.schema == { 47 | 'required': True, 48 | 'name': 'content', 49 | 'paramType': 'body', 50 | 'type': 'body_model', 51 | } 52 | 53 | 54 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 55 | def test_swagger_schema_for_request_not_found(schema): 56 | """Tests that validators_for_request() raises exceptions when 57 | a path is not found. 58 | """ 59 | # There exists a GET and POST for this endpoint. We should be able to call 60 | # either and have them pass validation. 61 | with pytest.raises(PathNotMatchedError) as excinfo: 62 | schema.validators_for_request( 63 | request=mock.Mock( 64 | path_info="/does_not_exist", 65 | method="GET" 66 | ), 67 | ) 68 | assert '/does_not_exist' in str(excinfo.value) 69 | assert 'Could not find ' in str(excinfo.value) 70 | 71 | 72 | def test_partial_path_match(): 73 | assert partial_path_match( 74 | '/v1/bing/forward_unstructured', 75 | '/v1/bing/forward_unstructured' 76 | ) 77 | assert partial_path_match( 78 | '/v1/{api_provider}/forward_unstructured', 79 | '/v1/bing/forward_unstructured' 80 | ) 81 | assert not partial_path_match( 82 | '/v1/google/forward_unstructured', 83 | '/v1/bing/forward_unstructured' 84 | ) 85 | 86 | 87 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 88 | def test_swagger_schema_for_request_virtual_subpath(schema): 89 | 90 | # There exists a GET and POST for this endpoint. We should be able to call 91 | # either and have them pass validation. 92 | value = schema.validators_for_request( 93 | request=mock.Mock( 94 | path="/subpath/sample", 95 | script_name="/subpath", 96 | path_info="/sample", 97 | method="GET" 98 | ), 99 | ) 100 | assert value.body.schema is None 101 | -------------------------------------------------------------------------------- /tests/acceptance/app/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import datetime 5 | 6 | import six 7 | import webob 8 | from pyramid.config import Configurator 9 | from pyramid.view import view_config 10 | 11 | 12 | @view_config(route_name='throw_400', renderer='json') 13 | def throw_error(request): 14 | request.response.status = webob.exc.HTTPBadRequest.code 15 | return dict(error=dict(details='Throwing error!')) 16 | 17 | 18 | @view_config(route_name='standard', renderer='json') 19 | def standard(request, path_arg): 20 | return { 21 | 'raw_response': 'foo', 22 | 'logging_info': {}, 23 | } 24 | 25 | 26 | @view_config(route_name='sample_nonstring', renderer='json') 27 | @view_config(route_name='get_with_non_string_query_args', renderer='json') 28 | @view_config(route_name='post_with_primitive_body', renderer='json') 29 | @view_config(route_name='sample_header', renderer='json') 30 | @view_config(route_name='sample_authentication', renderer='json') 31 | @view_config(route_name='sample_post', renderer='json') 32 | @view_config(route_name='post_with_form_params', renderer='json') 33 | @view_config(route_name='post_with_file_upload', renderer='json') 34 | def sample(request): 35 | if not request.registry.settings.get('skip_swagger_data_assert'): 36 | assert request.swagger_data 37 | return {} 38 | 39 | 40 | @view_config(route_name='echo_date_json_renderer', request_method='POST', renderer='json') 41 | @view_config(route_name='echo_date', request_method='POST', renderer='pyramid_swagger') 42 | def date_view(request): 43 | 44 | if '2.0' in request.registry.settings['pyramid_swagger.swagger_versions']: 45 | # Swagger 2.0 endpoint handling 46 | assert isinstance(request.swagger_data['body']['date'], datetime.date) 47 | else: 48 | assert isinstance(request.swagger_data['body']['date'], six.string_types) 49 | 50 | return request.swagger_data['body'] 51 | 52 | 53 | @view_config(route_name='post_endpoint_with_optional_body', request_method='POST', renderer='pyramid_swagger') 54 | @view_config(route_name='sample_no_response_schema', request_method='GET', renderer='pyramid_swagger') 55 | def post_endpoint_with_optional_body(request): 56 | return request.content_length 57 | 58 | 59 | @view_config(route_name='swagger_undefined', renderer='json') 60 | def swagger_undefined(request): 61 | return {} 62 | 63 | 64 | def main(global_config, **settings): 65 | """ Very basic pyramid app """ 66 | config = Configurator(settings=settings) 67 | 68 | config.include('pyramid_swagger') 69 | 70 | config.add_route( 71 | 'sample_nonstring', 72 | '/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}', 73 | ) 74 | config.add_route('standard', '/sample/{path_arg}/resource') 75 | config.add_route('get_with_non_string_query_args', '/get_with_non_string_query_args') 76 | config.add_route('post_with_primitive_body', '/post_with_primitive_body') 77 | config.add_route('post_with_form_params', '/post_with_form_params') 78 | config.add_route('post_with_file_upload', '/post_with_file_upload') 79 | config.add_route('sample_post', '/sample') 80 | config.include(include_samples, route_prefix='/sample') 81 | config.add_route('throw_400', '/throw_400') 82 | config.add_route('swagger_undefined', '/undefined/path') 83 | 84 | config.add_route('echo_date', '/echo_date') 85 | config.add_route('echo_date_json_renderer', '/echo_date_json_renderer') 86 | config.add_route('post_endpoint_with_optional_body', '/post_endpoint_with_optional_body') 87 | 88 | config.scan() 89 | return config.make_wsgi_app() 90 | 91 | 92 | def include_samples(config): 93 | config.add_route('sample_header', '/header') 94 | config.add_route('sample_authentication', '/authentication') 95 | config.add_route('sample_no_response_schema', '/no_response_schema') 96 | -------------------------------------------------------------------------------- /tests/includeme_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import mock 5 | import pytest 6 | from bravado_core.spec import Spec 7 | from pyramid.config import Configurator 8 | from pyramid.registry import Registry 9 | from swagger_spec_validator.common import SwaggerValidationError 10 | 11 | import pyramid_swagger 12 | from pyramid_swagger.model import SwaggerSchema 13 | 14 | 15 | @mock.patch('pyramid_swagger.register_api_doc_endpoints') 16 | @mock.patch('pyramid_swagger.get_swagger_schema') 17 | @mock.patch('pyramid_swagger.get_swagger_spec') 18 | def test_disable_api_doc_views(_1, _2, mock_register): 19 | settings = { 20 | 'pyramid_swagger.enable_api_doc_views': False, 21 | 'pyramid_swagger.enable_swagger_spec_validation': False, 22 | } 23 | 24 | mock_config = mock.Mock( 25 | spec=Configurator, 26 | registry=mock.Mock(spec=Registry, settings=settings)) 27 | 28 | pyramid_swagger.includeme(mock_config) 29 | assert not mock_register.called 30 | 31 | 32 | def test_bad_schema_validated_on_include(): 33 | settings = { 34 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/bad_app/', 35 | 'pyramid_swagger.enable_swagger_spec_validation': True, 36 | } 37 | mock_config = mock.Mock(registry=mock.Mock(settings=settings)) 38 | with pytest.raises(SwaggerValidationError): 39 | pyramid_swagger.includeme(mock_config) 40 | # TODO: Figure out why this assertion fails on travis 41 | # assert "'info' is a required property" in str(excinfo.value) 42 | 43 | 44 | @mock.patch('pyramid_swagger.get_swagger_spec') 45 | def test_bad_schema_not_validated_if_spec_validation_is_disabled(_): 46 | settings = { 47 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/bad_app/', 48 | 'pyramid_swagger.enable_swagger_spec_validation': False, 49 | 'pyramid_swagger.enable_api_doc_views': False, 50 | } 51 | mock_config = mock.Mock( 52 | spec=Configurator, registry=mock.Mock(settings=settings)) 53 | pyramid_swagger.includeme(mock_config) 54 | 55 | 56 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 57 | @mock.patch('pyramid_swagger.register_api_doc_endpoints') 58 | def test_swagger_12_only(mock_register): 59 | settings = { 60 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 61 | 'pyramid_swagger.swagger_versions': ['1.2'] 62 | } 63 | mock_config = mock.Mock(registry=mock.Mock(settings=settings)) 64 | pyramid_swagger.includeme(mock_config) 65 | assert isinstance(settings['pyramid_swagger.schema12'], SwaggerSchema) 66 | assert mock_register.call_count == 1 67 | 68 | 69 | @mock.patch('pyramid_swagger.register_api_doc_endpoints') 70 | def test_swagger_20_only(mock_register): 71 | settings = { 72 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 73 | 'pyramid_swagger.swagger_versions': ['2.0'] 74 | } 75 | mock_config = mock.Mock(registry=mock.Mock(settings=settings)) 76 | pyramid_swagger.includeme(mock_config) 77 | assert isinstance(settings['pyramid_swagger.schema20'], Spec) 78 | assert not settings['pyramid_swagger.schema12'] 79 | assert mock_register.call_count == 1 80 | 81 | 82 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 83 | @mock.patch('pyramid_swagger.register_api_doc_endpoints') 84 | def test_swagger_12_and_20(mock_register): 85 | settings = { 86 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 87 | 'pyramid_swagger.swagger_versions': ['1.2', '2.0'] 88 | } 89 | mock_config = mock.Mock(registry=mock.Mock(settings=settings)) 90 | pyramid_swagger.includeme(mock_config) 91 | assert isinstance(settings['pyramid_swagger.schema20'], Spec) 92 | assert isinstance(settings['pyramid_swagger.schema12'], SwaggerSchema) 93 | assert mock_register.call_count == 2 94 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/other_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/sample", 5 | "apis": [ 6 | { 7 | "path": "/sample/{path_arg}/resource", 8 | "operations": [ 9 | { 10 | "method": "GET", 11 | "nickname": "standard", 12 | "type": "standard_response", 13 | "parameters": [ 14 | { 15 | "paramType": "path", 16 | "name": "path_arg", 17 | "type": "string", 18 | "enum": ["path_arg1", "path_arg2"], 19 | "required": true 20 | }, 21 | { 22 | "paramType": "query", 23 | "name": "required_arg", 24 | "type": "string", 25 | "required": true 26 | }, 27 | { 28 | "paramType": "query", 29 | "name": "optional_arg", 30 | "type": "string", 31 | "required": false 32 | } 33 | ] 34 | } 35 | ] 36 | }, 37 | { 38 | "path": "/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}", 39 | "operations": [ 40 | { 41 | "method": "GET", 42 | "nickname": "sample_nonstring", 43 | "type": "void", 44 | "parameters": [ 45 | { 46 | "paramType": "path", 47 | "name": "int_arg", 48 | "type": "integer", 49 | "required": true 50 | }, 51 | { 52 | "paramType": "path", 53 | "name": "float_arg", 54 | "type": "number", 55 | "format": "float", 56 | "required": true 57 | }, 58 | { 59 | "paramType": "path", 60 | "name": "boolean_arg", 61 | "type": "boolean", 62 | "required": true 63 | } 64 | ] 65 | } 66 | ] 67 | }, 68 | { 69 | "path": "/sample/header", 70 | "operations": [ 71 | { 72 | "method": "GET", 73 | "nickname": "sample_header", 74 | "type": "void", 75 | "parameters": [ 76 | { 77 | "paramType": "header", 78 | "name": "X-Force", 79 | "type": "boolean", 80 | "required": true 81 | } 82 | ] 83 | } 84 | ] 85 | } 86 | ], 87 | "models": { 88 | "object": { 89 | "id": "object", 90 | "properties": { } 91 | }, 92 | "standard_response": { 93 | "id": "standard_response", 94 | "type": "object", 95 | "required": [ "raw_response", "logging_info" ], 96 | "additionalProperties": false, 97 | "properties": { 98 | "raw_response": { 99 | "type": "string" 100 | }, 101 | "logging_info": { 102 | "$ref": "object" 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/api_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | import mock 7 | import pytest 8 | import yaml 9 | from bravado_core.spec import Spec 10 | from pyramid.testing import DummyRequest 11 | 12 | from pyramid_swagger.api import build_swagger_12_api_declaration_view 13 | from pyramid_swagger.api import get_path_if_relative 14 | from pyramid_swagger.api import register_api_doc_endpoints 15 | from pyramid_swagger.ingest import API_DOCS_FILENAME 16 | from pyramid_swagger.ingest import ApiDeclarationNotFoundError 17 | from pyramid_swagger.ingest import ResourceListingNotFoundError 18 | from tests.acceptance.response_test import get_registry 19 | from tests.acceptance.response_test import get_swagger_schema 20 | 21 | 22 | def test_basepath_rewriting(): 23 | resource_json = {'basePath': 'bar'} 24 | view = build_swagger_12_api_declaration_view(resource_json) 25 | request = DummyRequest(application_url='foo') 26 | result = view(request) 27 | assert result['basePath'] == request.application_url 28 | assert result['basePath'] != resource_json['basePath'] 29 | 30 | 31 | def build_config(schema_dir): 32 | return mock.Mock( 33 | registry=get_registry({ 34 | 'swagger_schema': get_swagger_schema(schema_dir), 35 | }) 36 | ) 37 | 38 | 39 | def test_proper_error_on_missing_resource_listing(): 40 | with pytest.raises(ResourceListingNotFoundError) as exc: 41 | register_api_doc_endpoints( 42 | build_config( 43 | 'tests/sample_schemas/missing_resource_listing/api_docs.json'), 44 | ) 45 | assert( 46 | 'tests/sample_schemas/missing_resource_listing/' in str(exc.value) 47 | ) 48 | assert 'must be named {0}'.format(API_DOCS_FILENAME) in str(exc.value) 49 | 50 | 51 | def test_proper_error_on_missing_api_declaration(): 52 | with pytest.raises(ApiDeclarationNotFoundError) as exc: 53 | register_api_doc_endpoints( 54 | build_config('tests/sample_schemas/missing_api_declaration/'), 55 | ) 56 | assert ( 57 | 'tests/sample_schemas/missing_api_declaration/missing.json' 58 | in str(exc.value) 59 | ) 60 | 61 | 62 | def test_ignore_absolute_paths(): 63 | """ 64 | we don't have the ability to automagically translate these external 65 | resources from yaml to json and vice versa, so ignore them altogether. 66 | """ 67 | assert get_path_if_relative( 68 | 'http://www.google.com/some/special/schema.json', 69 | ) is None 70 | 71 | assert get_path_if_relative( 72 | '//www.google.com/some/schema.yaml', 73 | ) is None 74 | 75 | assert get_path_if_relative( 76 | '/usr/lib/shared/schema.json', 77 | ) is None 78 | 79 | 80 | def test_resolve_nested_refs(): 81 | """ 82 | Make sure we resolve nested refs gracefully and not get lost in 83 | the recursion. Also make sure we don't rely on dictionary order 84 | """ 85 | os.environ["PYTHONHASHSEED"] = str(1) 86 | with open('tests/sample_schemas/nested_defns/swagger.yaml') as swagger_spec: 87 | spec_dict = yaml.safe_load(swagger_spec) 88 | spec = Spec.from_dict(spec_dict, '') 89 | assert spec.flattened_spec 90 | 91 | 92 | def traverse_spec(swagger_spec): 93 | for k, v in swagger_spec.items(): 94 | if k == "": 95 | raise Exception('Empty key detected in the swagger spec.') 96 | elif isinstance(v, dict): 97 | return traverse_spec(v) 98 | elif isinstance(v, list): 99 | for item in v: 100 | if isinstance(item, dict): 101 | return traverse_spec(item) 102 | return 103 | 104 | 105 | def test_extenal_refs_no_empty_keys(): 106 | """ 107 | This test ensures that we never use empty strings as 108 | keys swagger specs. 109 | """ 110 | with open('tests/sample_schemas/external_refs/swagger.json') as swagger_spec: 111 | spec_dict = yaml.safe_load(swagger_spec) 112 | path = 'file:' + os.getcwd() + '/tests/sample_schemas/external_refs/swagger.json' 113 | spec = Spec.from_dict(spec_dict, path) 114 | flattened_spec = spec.flattened_spec 115 | traverse_spec(flattened_spec) 116 | -------------------------------------------------------------------------------- /tests/acceptance/yaml_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import base64 5 | import copy 6 | import json 7 | 8 | import pytest 9 | import yaml 10 | from bravado_core.schema import is_dict_like 11 | from bravado_core.schema import is_list_like 12 | from six import iterkeys 13 | from webtest import TestApp as App 14 | 15 | from pyramid_swagger.tween import SwaggerFormat 16 | from tests.acceptance.app import main 17 | 18 | 19 | @pytest.fixture 20 | def settings(): 21 | dir_path = 'tests/sample_schemas/yaml_app/' 22 | return { 23 | 'pyramid_swagger.schema_file': 'swagger.yaml', 24 | 'pyramid_swagger.schema_directory': dir_path, 25 | 'pyramid_swagger.enable_request_validation': True, 26 | 'pyramid_swagger.enable_swagger_spec_validation': True, 27 | } 28 | 29 | 30 | @pytest.fixture 31 | def yaml_app(): 32 | return SwaggerFormat(format='base64', 33 | to_wire=base64.b64encode, 34 | to_python=base64.b64decode, 35 | validate=base64.b64decode, 36 | description='base64') 37 | 38 | 39 | @pytest.fixture 40 | def testapp_with_base64(settings, yaml_app): 41 | """Fixture for setting up a Swagger 2.0 version of the test testapp.""" 42 | settings['pyramid_swagger.swagger_versions'] = ['2.0'] 43 | settings['pyramid_swagger.user_formats'] = [yaml_app] 44 | return App(main({}, **settings)) 45 | 46 | 47 | def test_user_format_happy_case(testapp_with_base64): 48 | response = testapp_with_base64.get('/sample/path_arg1/resource', 49 | params={'required_arg': 'MQ=='},) 50 | assert response.status_code == 200 51 | 52 | 53 | def test_user_format_failure_case(testapp_with_base64): 54 | # 'MQ' is not a valid base64 encoded string. 55 | with pytest.raises(Exception): 56 | testapp_with_base64.get('/sample/path_arg1/resource', 57 | params={'required_arg': 'MQ'},) 58 | 59 | 60 | def _strip_xmodel(spec_dict): 61 | """ 62 | :param spec_dict: Swagger spec in dict form. This is treated as read-only. 63 | :return: deep copy of spec_dict with the x-model vendor extension stripped out. 64 | """ 65 | result = copy.deepcopy(spec_dict) 66 | 67 | def descend(fragment): 68 | if is_dict_like(fragment): 69 | fragment.pop('x-model', None) # Removes 'x-model' key if present 70 | for key in iterkeys(fragment): 71 | descend(fragment[key]) 72 | elif is_list_like(fragment): 73 | for element in fragment: 74 | descend(element) 75 | 76 | descend(result) 77 | return result 78 | 79 | 80 | def validate_json_response(response, expected_dict): 81 | # webob < 1.7 returns the charset, webob >= 1.7 does not 82 | # see https://github.com/striglia/pyramid_swagger/issues/185 83 | assert response.headers['content-type'] in \ 84 | ('application/json', 'application/json; charset=UTF-8') 85 | assert _strip_xmodel(json.loads(response.body.decode("utf-8"))) == expected_dict 86 | 87 | 88 | def validate_yaml_response(response, expected_dict): 89 | assert response.headers['content-type'] == 'application/x-yaml; charset=UTF-8' 90 | assert _strip_xmodel(yaml.safe_load(response.body)) == expected_dict 91 | 92 | 93 | def _rewrite_ref(ref, schema_format): 94 | if schema_format == 'yaml': 95 | return ref # all refs are already yaml 96 | return ref.replace('.yaml', '.%s' % schema_format) 97 | 98 | 99 | def _recursively_rewrite_refs(schema_item, schema_format): 100 | if isinstance(schema_item, dict): 101 | for key, value in schema_item.items(): 102 | if key == '$ref': 103 | schema_item[key] = _rewrite_ref(value, schema_format) 104 | else: 105 | _recursively_rewrite_refs(value, schema_format) 106 | elif isinstance(schema_item, list): 107 | for item in schema_item: 108 | _recursively_rewrite_refs(item, schema_format) 109 | 110 | 111 | @pytest.mark.parametrize('schema_format', ['json', 'yaml']) 112 | @pytest.mark.parametrize('test_file', ['swagger', 'defs']) 113 | def test_swagger_json_api_doc_route(testapp_with_base64, test_file, schema_format): 114 | validation_method = { 115 | 'yaml': validate_yaml_response, 116 | 'json': validate_json_response, 117 | } 118 | 119 | url = '/%s.%s' % (test_file, schema_format) 120 | response = testapp_with_base64.get(url) 121 | assert response.status_code == 200 122 | 123 | fname = 'tests/sample_schemas/yaml_app/%s.yaml' % test_file 124 | with open(fname, 'r') as f: 125 | expected_schema = yaml.safe_load(f) 126 | 127 | _recursively_rewrite_refs(expected_schema, schema_format) 128 | 129 | validation_method[schema_format](response, expected_schema) 130 | -------------------------------------------------------------------------------- /tests/acceptance/relative_ref_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import json 5 | import os.path 6 | import re 7 | import sys 8 | 9 | import pytest 10 | import yaml 11 | from six import BytesIO 12 | from webtest import TestApp as App 13 | 14 | from tests.acceptance.app import main 15 | 16 | 17 | DESERIALIZERS = { 18 | 'json': lambda r: json.loads(r.body.decode('utf-8')), 19 | 'yaml': lambda r: yaml.safe_load(BytesIO(r.body)), 20 | } 21 | 22 | 23 | @pytest.fixture 24 | def settings(): 25 | dir_path = 'tests/sample_schemas/relative_ref/' 26 | return { 27 | 'pyramid_swagger.schema_directory': dir_path, 28 | 'pyramid_swagger.enable_request_validation': True, 29 | 'pyramid_swagger.enable_swagger_spec_validation': True, 30 | 'pyramid_swagger.swagger_versions': ['2.0'] 31 | } 32 | 33 | 34 | @pytest.fixture 35 | def test_app(settings): 36 | """Fixture for setting up a Swagger 2.0 version of the test test_app.""" 37 | return App(main({}, **settings)) 38 | 39 | 40 | @pytest.fixture 41 | def test_app_deref(settings): 42 | """Fixture for setting up a Swagger 2.0 version of the test test_app 43 | test app serves swagger schemas with refs dereferenced.""" 44 | settings['pyramid_swagger.dereference_served_schema'] = True 45 | return App(main({}, **settings)) 46 | 47 | 48 | def test_running_query_for_relative_ref(test_app): 49 | response = test_app.get('/sample/path_arg1/resource', params={},) 50 | assert response.status_code == 200 51 | 52 | 53 | def translate_ref_extension(ref, schema_format): 54 | if schema_format == 'json': 55 | return ref # all refs are already yaml 56 | return ref.replace('.json', '.%s' % schema_format) 57 | 58 | 59 | def recursively_rewrite_refs(schema_item, schema_format): 60 | """ 61 | Fix a schema's refs so that they all read the same format. Ensures that 62 | consumers requesting a yaml resource don't have to know how to read json. 63 | """ 64 | if isinstance(schema_item, dict): 65 | for key, value in schema_item.items(): 66 | if key == '$ref': 67 | schema_item[key] = translate_ref_extension( 68 | value, schema_format, 69 | ) 70 | else: 71 | recursively_rewrite_refs(value, schema_format) 72 | elif isinstance(schema_item, list): 73 | for item in schema_item: 74 | recursively_rewrite_refs(item, schema_format) 75 | 76 | 77 | @pytest.mark.parametrize('schema_format', ['json', 'yaml', ]) 78 | def test_swagger_schema_retrieval(schema_format, test_app): 79 | here = os.path.dirname(__file__) 80 | deserializer = DESERIALIZERS[schema_format] 81 | 82 | expected_files = [ 83 | 'parameters/common', 84 | 'paths/common', 85 | 'responses/common', 86 | 'swagger', 87 | ] 88 | for expected_file in expected_files: 89 | response = test_app.get( 90 | '/{0}.{1}'.format(expected_file, schema_format) 91 | ) 92 | assert response.status_code == 200 93 | 94 | gold_path_parts = [ 95 | here, 96 | '..', 97 | 'sample_schemas', 98 | 'relative_ref', 99 | '{0}.json'.format(expected_file), 100 | ] 101 | with open(os.path.join(*gold_path_parts)) as f: 102 | expected_dict = json.load(f) 103 | 104 | recursively_rewrite_refs(expected_dict, schema_format) 105 | 106 | actual_dict = deserializer(response) 107 | 108 | assert actual_dict == expected_dict 109 | 110 | 111 | @pytest.mark.parametrize('schema_format', ['json', 'yaml', ]) 112 | def test_swagger_schema_retrieval_is_not_dereferenced(schema_format, test_app): 113 | 114 | response = test_app.get('/swagger.{0}'.format(schema_format)) 115 | 116 | here = os.path.dirname(__file__) 117 | swagger_path_parts = [ 118 | here, 119 | '..', 120 | 'sample_schemas', 121 | 'relative_ref', 122 | 'dereferenced_swagger.json' 123 | ] 124 | dereferenced_swagger_path = os.path.join(*swagger_path_parts) 125 | with open(dereferenced_swagger_path) as swagger_file: 126 | expected_dict = json.load(swagger_file) 127 | 128 | deserializer = DESERIALIZERS[schema_format] 129 | actual_dict = deserializer(response) 130 | 131 | assert '"$ref"' in json.dumps(actual_dict) 132 | assert actual_dict != expected_dict 133 | 134 | 135 | @pytest.mark.parametrize('schema_format', ['json', 'yaml', ]) 136 | def test_dereferenced_swagger_schema_retrieval(schema_format, test_app_deref): 137 | 138 | response = test_app_deref.get('/swagger.{0}'.format(schema_format)) 139 | 140 | here = os.path.dirname(__file__) 141 | swagger_path_parts = [ 142 | here, 143 | '..', 144 | 'sample_schemas', 145 | 'relative_ref', 146 | 'dereferenced_swagger.json' 147 | ] 148 | dereferenced_swagger_path = os.path.join(*swagger_path_parts) 149 | with open(dereferenced_swagger_path) as swagger_file: 150 | expected_dict = json.load(swagger_file) 151 | 152 | deserializer = DESERIALIZERS[schema_format] 153 | actual_dict = deserializer(response) 154 | 155 | # pattern for references outside the current file 156 | ref_pattern = re.compile(r'("\$ref": "[^#][^"]*")') 157 | assert ref_pattern.findall(json.dumps(actual_dict)) == [] 158 | 159 | if sys.platform != 'win32': 160 | # This checks that the returned dictionary matches the expected one 161 | # as this check mainly validates the bravado-core performs valid flattening 162 | # of specs and bravado-core flattening could provide different results 163 | # (in terms of references names) according to the platform we decided 164 | # to not check it for windows 165 | assert actual_dict == expected_dict 166 | -------------------------------------------------------------------------------- /tests/acceptance/api_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | import pytest 7 | from webtest import TestApp as App 8 | 9 | from tests.acceptance.app import main 10 | 11 | 12 | @pytest.fixture 13 | def settings(): 14 | return { 15 | 'pyramid_swagger.schema_directory': os.path.join('tests', 'sample_schemas', 'good_app'), 16 | 'pyramid_swagger.enable_swagger_spec_validation': False, 17 | } 18 | 19 | 20 | @pytest.fixture 21 | def default_test_app(settings): 22 | return App(main({}, **settings)) 23 | 24 | 25 | @pytest.fixture 26 | def swagger_20_test_app(settings): 27 | """Fixture for setting up a Swagger 2.0 version of the test test_app.""" 28 | settings['pyramid_swagger.swagger_versions'] = ['2.0'] 29 | return App(main({}, **settings)) 30 | 31 | 32 | @pytest.fixture 33 | def swagger_12_test_app(settings): 34 | """Fixture for setting up a Swagger 1.2 version of the test test_app.""" 35 | settings['pyramid_swagger.swagger_versions'] = ['1.2'] 36 | return App(main({}, **settings)) 37 | 38 | 39 | @pytest.fixture 40 | def swagger_12_and_20_test_app(settings): 41 | """Fixture for setting up a Swagger 1.2 and Swagger 2.0 version of the 42 | test test_app. 43 | """ 44 | settings['pyramid_swagger.swagger_versions'] = ['1.2', '2.0'] 45 | return App(main({}, **settings)) 46 | 47 | 48 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 49 | def test_12_api_docs(swagger_12_test_app): 50 | response = swagger_12_test_app.get('/api-docs', status=200) 51 | assert response.json['swaggerVersion'] == '1.2' 52 | 53 | 54 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 55 | def test_12_sample_resource(swagger_12_test_app): 56 | response = swagger_12_test_app.get('/api-docs/sample', status=200) 57 | assert response.json['swaggerVersion'] == '1.2' 58 | 59 | 60 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 61 | def test_12_other_sample_resource(swagger_12_test_app): 62 | response = swagger_12_test_app.get('/api-docs/other_sample', status=200) 63 | assert response.json['swaggerVersion'] == '1.2' 64 | 65 | 66 | def test_20_schema(swagger_20_test_app): 67 | response = swagger_20_test_app.get('/swagger.json', status=200) 68 | assert response.json['swagger'] == '2.0' 69 | 70 | 71 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 72 | def test_12_and_20_schemas(swagger_12_and_20_test_app): 73 | for path in ('/api-docs', '/api-docs/sample', '/api-docs/other_sample'): 74 | response12 = swagger_12_and_20_test_app.get(path, status=200) 75 | assert response12.json['swaggerVersion'] == '1.2' 76 | 77 | response20 = swagger_12_and_20_test_app.get('/swagger.json', status=200) 78 | assert response20.json['swagger'] == '2.0' 79 | 80 | 81 | def test_default_only_serves_up_swagger_20_schema(default_test_app): 82 | response = default_test_app.get('/swagger.json', status=200) 83 | assert response.json['swagger'] == '2.0' 84 | 85 | # swagger 1.2 schemas should 404 86 | for path in ('/api-docs', '/api-docs/sample', '/api-docs/other_sample'): 87 | default_test_app.get(path, status=404) 88 | 89 | 90 | def test_recursive_swagger_api_internal_refs(): 91 | recursive_test_app = App(main({}, **{ 92 | 'pyramid_swagger.schema_directory': 93 | 'tests/sample_schemas/recursive_app/internal/', 94 | })) 95 | 96 | recursive_test_app.get('/swagger.json', status=200) 97 | 98 | 99 | def test_recursive_swagger_api_external_refs(): 100 | recursive_test_app = App(main({}, **{ 101 | 'pyramid_swagger.schema_directory': 102 | 'tests/sample_schemas/recursive_app/external/', 103 | })) 104 | 105 | recursive_test_app.get('/swagger.json', status=200) 106 | recursive_test_app.get('/external.json', status=200) 107 | 108 | 109 | def test_base_path_api_docs_on_good_app_schema(): 110 | base_path = '/web/base/path' 111 | 112 | recursive_test_app = App(main({}, **{ 113 | 'pyramid_swagger.schema_directory': 114 | 'tests/sample_schemas/good_app/', 115 | 'pyramid_swagger.base_path_api_docs': 116 | base_path 117 | })) 118 | 119 | recursive_test_app.get(base_path + '/swagger.json', status=200) 120 | recursive_test_app.get('/swagger.json', status=404) 121 | 122 | 123 | def test_base_path_api_docs_on_recursive_app_schema(): 124 | base_path = '/some/path' 125 | recursive_test_app = App(main({}, **{ 126 | 'pyramid_swagger.schema_directory': 127 | 'tests/sample_schemas/recursive_app/external/', 128 | 'pyramid_swagger.base_path_api_docs': 129 | base_path 130 | })) 131 | 132 | recursive_test_app.get(base_path + '/swagger.json', status=200) 133 | recursive_test_app.get('/swagger.json', status=404) 134 | recursive_test_app.get(base_path + '/external.json', status=200) 135 | recursive_test_app.get('/external.json', status=404) 136 | 137 | 138 | def test_base_path_api_docs_with_script_name_on_recursive_app_schema(): 139 | base_path = '/some/path' 140 | script_name = '/scriptname' 141 | test_app = App(main({}, **{ 142 | 'pyramid_swagger.schema_directory': 143 | 'tests/sample_schemas/recursive_app/external/', 144 | 'pyramid_swagger.base_path_api_docs': 145 | base_path}), 146 | {'SCRIPT_NAME': script_name}) 147 | 148 | test_app.get(script_name + base_path + '/swagger.json', status=200) 149 | test_app.get('/swagger.json', status=404) 150 | 151 | test_app.get(script_name + base_path + '/external.json', status=200) 152 | test_app.get('/external.json', status=404) 153 | 154 | 155 | def test_virtual_subpath(settings): 156 | test_app = App(main({}, **settings), {'SCRIPT_NAME': '/subpath'}) 157 | test_app.get('/subpath/swagger.json', status=200) 158 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "0.1", 3 | "swaggerVersion": "1.2", 4 | "basePath": "http://localhost:9999/sample", 5 | "apis": [ 6 | { 7 | "path": "/sample", 8 | "operations": [ 9 | { 10 | "method": "GET", 11 | "nickname": "sample_get", 12 | "type": "object", 13 | "parameters": [] 14 | }, 15 | { 16 | "method": "POST", 17 | "nickname": "sample_post", 18 | "type": "object", 19 | "parameters": [ 20 | { 21 | "paramType": "query", 22 | "name": "optional_string", 23 | "type": "string", 24 | "required": false 25 | }, 26 | { 27 | "paramType": "body", 28 | "name": "content", 29 | "type": "body_model", 30 | "required": true 31 | } 32 | ] 33 | } 34 | ] 35 | }, 36 | { 37 | "path": "/sample_array_response", 38 | "operations": [ 39 | { 40 | "method": "GET", 41 | "nickname": "sample_get_array_response", 42 | "parameters": [], 43 | "type": "array", 44 | "items": { 45 | "$ref": "array_content_model" 46 | } 47 | } 48 | ] 49 | }, 50 | { 51 | "path": "/post_with_primitive_body", 52 | "operations": [ 53 | { 54 | "method": "POST", 55 | "nickname": "post_with_primitive_body", 56 | "type": "object", 57 | "parameters": [ 58 | { 59 | "paramType": "body", 60 | "name": "content", 61 | "type": "array", 62 | "items": { "type": "string" }, 63 | "required": true 64 | } 65 | ] 66 | } 67 | ] 68 | }, 69 | { 70 | "path": "/post_with_form_params", 71 | "operations": [ 72 | { 73 | "method": "POST", 74 | "nickname": "post_with_form_params", 75 | "type": "object", 76 | "parameters": [ 77 | { 78 | "name": "form_param", 79 | "type": "integer", 80 | "paramType": "form", 81 | "required": true 82 | } 83 | ] 84 | } 85 | ] 86 | }, 87 | { 88 | "path": "/post_with_file_upload", 89 | "operations": [ 90 | { 91 | "method": "POST", 92 | "consumes": ["multipart/form-data"], 93 | "nickname": "post_with_file_upload", 94 | "type": "object", 95 | "parameters": [ 96 | { 97 | "name": "photo_file", 98 | "type": "File", 99 | "paramType": "form", 100 | "required": true 101 | } 102 | ] 103 | } 104 | ] 105 | }, 106 | { 107 | "path": "/get_with_non_string_query_args", 108 | "operations": [ 109 | { 110 | "method": "GET", 111 | "nickname": "get_with_non_string_query_args", 112 | "type": "object", 113 | "parameters": [ 114 | { 115 | "paramType": "query", 116 | "name": "int_arg", 117 | "type": "integer", 118 | "required": true 119 | }, 120 | { 121 | "paramType": "query", 122 | "name": "float_arg", 123 | "type": "number", 124 | "format": "float", 125 | "required": true 126 | }, 127 | { 128 | "paramType": "query", 129 | "name": "boolean_arg", 130 | "type": "boolean", 131 | "required": true 132 | } 133 | ] 134 | } 135 | ] 136 | } 137 | ], 138 | "models": { 139 | "object": { 140 | "id": "object", 141 | "properties": { } 142 | }, 143 | "body_model": { 144 | "id": "body_model", 145 | "type": "object", 146 | "required": [ "foo" ], 147 | "additionalProperties": false, 148 | "properties": { 149 | "foo": { "type": "string" }, 150 | "bar": { "type": "string" } 151 | } 152 | }, 153 | "standard_response": { 154 | "id": "standard_response", 155 | "type": "object", 156 | "required": [ "raw_response", "logging_info" ], 157 | "additionalProperties": false, 158 | "properties": { 159 | "raw_response": { 160 | "type": "string" 161 | }, 162 | "logging_info": { 163 | "$ref": "object" 164 | } 165 | } 166 | }, 167 | "array_content_model": { 168 | "id": "array_content_model", 169 | "required": ["enum_value"], 170 | "properties": { 171 | "enum_value": { 172 | "type": "string", 173 | "enum": [ 174 | "good_enum_value" 175 | ] 176 | } 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/ingest_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import os.path 5 | 6 | import mock 7 | import pytest 8 | import simplejson 9 | 10 | from pyramid_swagger.ingest import _load_resource_listing 11 | from pyramid_swagger.ingest import API_DOCS_FILENAME 12 | from pyramid_swagger.ingest import ApiDeclarationNotFoundError 13 | from pyramid_swagger.ingest import BRAVADO_CORE_CONFIG_PREFIX 14 | from pyramid_swagger.ingest import create_bravado_core_config 15 | from pyramid_swagger.ingest import generate_resource_listing 16 | from pyramid_swagger.ingest import get_resource_listing 17 | from pyramid_swagger.ingest import get_swagger_schema 18 | from pyramid_swagger.ingest import get_swagger_spec 19 | from pyramid_swagger.ingest import ingest_resources 20 | from pyramid_swagger.ingest import ResourceListingGenerationError 21 | from pyramid_swagger.ingest import ResourceListingNotFoundError 22 | from pyramid_swagger.tween import SwaggerFormat 23 | 24 | 25 | def test_proper_error_on_missing_resource_listing(): 26 | filename = 'tests/sample_schemas/missing_resource_listing/api_docs.json' 27 | with pytest.raises(ResourceListingNotFoundError) as exc: 28 | _load_resource_listing(filename) 29 | assert filename in str(exc.value) 30 | assert 'must be named {0}'.format(API_DOCS_FILENAME) in str(exc.value) 31 | 32 | 33 | def test_proper_error_on_missing_api_declaration(): 34 | with pytest.raises(ApiDeclarationNotFoundError) as exc: 35 | ingest_resources( 36 | {'sample_resource': 'fake/sample_resource.json'}, 37 | 'fake', 38 | ) 39 | assert 'fake/sample_resource.json' in str(exc.value) 40 | 41 | 42 | @mock.patch('pyramid_swagger.ingest.build_http_handlers', 43 | return_value={'file': mock.Mock()}) 44 | @mock.patch('os.path.abspath', return_value='/bar/foo/swagger.json') 45 | @mock.patch('pyramid_swagger.ingest.Spec.from_dict') 46 | def test_get_swagger_spec_passes_absolute_url( 47 | mock_spec, mock_abs, mock_http_handlers, 48 | ): 49 | get_swagger_spec({'pyramid_swagger.schema_directory': 'foo/'}) 50 | mock_abs.assert_called_once_with('foo/swagger.json') 51 | expected_url = "file:///bar/foo/swagger.json" 52 | mock_spec.assert_called_once_with(mock.ANY, config=mock.ANY, 53 | origin_url=expected_url) 54 | 55 | 56 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 57 | def test_get_swagger_schema_default(): 58 | settings = { 59 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 60 | } 61 | 62 | swagger_schema = get_swagger_schema(settings) 63 | assert len(swagger_schema.pyramid_endpoints) == 6 64 | assert swagger_schema.resource_validators 65 | 66 | 67 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 68 | def test_get_swagger_schema_no_validation(): 69 | settings = { 70 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/bad_app/', 71 | 'pyramid_swagger.enable_swagger_spec_validation': False, 72 | } 73 | # No error means we skipped validation of the bad schema 74 | get_swagger_schema(settings) 75 | 76 | 77 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 78 | def test_generate_resource_listing(): 79 | listing = {'swaggerVersion': 1.2} 80 | 81 | listing = generate_resource_listing( 82 | 'tests/sample_schemas/good_app/', 83 | listing 84 | ) 85 | 86 | expected = { 87 | 'swaggerVersion': 1.2, 88 | 'apis': [ 89 | {'path': '/echo_date'}, 90 | {'path': '/no_models'}, 91 | {'path': '/other_sample'}, 92 | {'path': '/post_endpoint_with_optional_body'}, 93 | {'path': '/sample'}, 94 | ] 95 | } 96 | assert listing == expected 97 | 98 | 99 | def test_generate_resource_listing_with_existing_listing(): 100 | listing = { 101 | 'apis': [{'path': '/something'}] 102 | } 103 | with pytest.raises(ResourceListingGenerationError) as exc: 104 | generate_resource_listing('tests/sample_schemas/good_app/', listing) 105 | 106 | assert 'Generating a listing would override' in str(exc.value) 107 | 108 | 109 | @mock.patch('pyramid_swagger.ingest.generate_resource_listing', autospec=True) 110 | @mock.patch('pyramid_swagger.ingest._load_resource_listing', autospec=True) 111 | def test_get_resource_listing_generated(mock_load, mock_generate): 112 | schema_dir = '/api_docs' 113 | resource_listing = get_resource_listing(schema_dir, True) 114 | mock_generate.assert_called_once_with(schema_dir, mock_load.return_value) 115 | assert resource_listing == mock_generate.return_value 116 | 117 | 118 | def test_get_resource_listing_default(): 119 | schema_dir = 'tests/sample_schemas/good_app/' 120 | resource_listing = get_resource_listing(schema_dir, False) 121 | 122 | with open(os.path.join(schema_dir, 'api_docs.json')) as fh: 123 | assert resource_listing == simplejson.load(fh) 124 | 125 | 126 | def test_create_bravado_core_config_with_defaults(): 127 | assert {'use_models': False} == create_bravado_core_config({}) 128 | 129 | 130 | @pytest.fixture 131 | def bravado_core_formats(): 132 | return [mock.Mock(spec=SwaggerFormat)] 133 | 134 | 135 | @pytest.fixture 136 | def bravado_core_configs(bravado_core_formats): 137 | return { 138 | 'validate_requests': True, 139 | 'validate_responses': False, 140 | 'validate_swagger_spec': True, 141 | 'use_models': True, 142 | 'formats': bravado_core_formats, 143 | 'include_missing_properties': False 144 | } 145 | 146 | 147 | def test_create_bravado_core_config_non_empty_deprecated_configs( 148 | bravado_core_formats, bravado_core_configs, 149 | ): 150 | pyramid_swagger_config = { 151 | 'pyramid_swagger.enable_request_validation': True, 152 | 'pyramid_swagger.enable_response_validation': False, 153 | 'pyramid_swagger.enable_swagger_spec_validation': True, 154 | 'pyramid_swagger.use_models': True, 155 | 'pyramid_swagger.user_formats': bravado_core_formats, 156 | 'pyramid_swagger.include_missing_properties': False, 157 | } 158 | 159 | bravado_core_config = create_bravado_core_config(pyramid_swagger_config) 160 | 161 | assert bravado_core_configs == bravado_core_config 162 | 163 | 164 | def test_create_bravado_core_config_with_passthrough_configs(bravado_core_formats, bravado_core_configs): 165 | pyramid_swagger_config = { 166 | '{}validate_requests'.format(BRAVADO_CORE_CONFIG_PREFIX): True, 167 | '{}validate_responses'.format(BRAVADO_CORE_CONFIG_PREFIX): False, 168 | '{}validate_swagger_spec'.format(BRAVADO_CORE_CONFIG_PREFIX): True, 169 | '{}use_models'.format(BRAVADO_CORE_CONFIG_PREFIX): True, 170 | '{}formats'.format(BRAVADO_CORE_CONFIG_PREFIX): bravado_core_formats, 171 | '{}include_missing_properties'.format(BRAVADO_CORE_CONFIG_PREFIX): False 172 | } 173 | 174 | bravado_core_config = create_bravado_core_config(pyramid_swagger_config) 175 | 176 | assert bravado_core_configs == bravado_core_config 177 | -------------------------------------------------------------------------------- /tests/sample_schemas/bad_app/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "paths": { 4 | "/no_models": { 5 | "get": { 6 | "responses": { 7 | "200": { 8 | "description": "No response was specified" 9 | } 10 | }, 11 | "description": "", 12 | "operationId": "no_models_get" 13 | } 14 | }, 15 | "/sample/{path_arg}/resource": { 16 | "get": { 17 | "responses": { 18 | "200": { 19 | "description": "No response was specified" 20 | } 21 | }, 22 | "description": "", 23 | "operationId": "standard", 24 | "parameters": [ 25 | { 26 | "in": "path", 27 | "name": "path_arg", 28 | "required": true, 29 | "type": "string", 30 | "enum": ["path_arg1", "path_arg2"] 31 | }, 32 | { 33 | "in": "query", 34 | "name": "required_arg", 35 | "required": true, 36 | "type": "string" 37 | }, 38 | { 39 | "in": "query", 40 | "name": "optional_arg", 41 | "required": false, 42 | "type": "string" 43 | } 44 | ] 45 | } 46 | }, 47 | "/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}": { 48 | "get": { 49 | "responses": { 50 | "200": { 51 | "description": "No response was specified" 52 | } 53 | }, 54 | "description": "", 55 | "operationId": "sample_nonstring", 56 | "parameters": [ 57 | { 58 | "in": "path", 59 | "name": "int_arg", 60 | "required": true, 61 | "type": "integer" 62 | }, 63 | { 64 | "in": "path", 65 | "name": "float_arg", 66 | "required": true, 67 | "type": "number" 68 | }, 69 | { 70 | "in": "path", 71 | "name": "boolean_arg", 72 | "required": true, 73 | "type": "boolean" 74 | } 75 | ] 76 | } 77 | }, 78 | "/sample/header": { 79 | "get": { 80 | "responses": { 81 | "200": { 82 | "description": "No response was specified" 83 | } 84 | }, 85 | "description": "", 86 | "operationId": "sample_header", 87 | "parameters": [ 88 | { 89 | "in": "header", 90 | "name": "X-Force", 91 | "required": true, 92 | "type": "boolean" 93 | } 94 | ] 95 | } 96 | }, 97 | "/sample": { 98 | "get": { 99 | "responses": { 100 | "200": { 101 | "description": "No response was specified" 102 | } 103 | }, 104 | "description": "", 105 | "operationId": "sample_get" 106 | }, 107 | "post": { 108 | "responses": { 109 | "200": { 110 | "description": "No response was specified" 111 | } 112 | }, 113 | "description": "", 114 | "operationId": "sample_post", 115 | "parameters": [ 116 | { 117 | "in": "query", 118 | "name": "optional_string", 119 | "required": false, 120 | "type": "string" 121 | }, 122 | { 123 | "in": "body", 124 | "name": "content", 125 | "required": true, 126 | "schema": { 127 | "$ref": "#/definitions/body_model" 128 | } 129 | } 130 | ] 131 | } 132 | }, 133 | "/sample_array_response": { 134 | "get": { 135 | "responses": { 136 | "200": { 137 | "description": "No response was specified" 138 | } 139 | }, 140 | "description": "", 141 | "operationId": "sample_get_array_response" 142 | } 143 | }, 144 | "/post_with_primitive_body": { 145 | "post": { 146 | "responses": { 147 | "200": { 148 | "description": "No response was specified" 149 | } 150 | }, 151 | "description": "", 152 | "operationId": "post_with_primitive_body", 153 | "parameters": [ 154 | { 155 | "in": "body", 156 | "name": "content", 157 | "required": true, 158 | "schema": { 159 | "type": "array", 160 | "items": { 161 | "type": "string" 162 | } 163 | } 164 | } 165 | ] 166 | } 167 | }, 168 | "/post_with_form_params": { 169 | "post": { 170 | "200": { 171 | "description": "No response was specified" 172 | }, 173 | "description": "", 174 | "operationId": "post_with_form_params", 175 | "parameters": [ 176 | { 177 | "in": "formData", 178 | "name": "form_param", 179 | "required": true, 180 | "type": "integer" 181 | } 182 | ] 183 | } 184 | }, 185 | "/get_with_non_string_query_args": { 186 | "get": { 187 | "responses": { 188 | "200": { 189 | "description": "No response was specified" 190 | } 191 | }, 192 | "description": "", 193 | "operationId": "get_with_non_string_query_args", 194 | "parameters": [ 195 | { 196 | "in": "query", 197 | "name": "int_arg", 198 | "required": true, 199 | "type": "integer" 200 | }, 201 | { 202 | "in": "query", 203 | "name": "float_arg", 204 | "required": true, 205 | "type": "number" 206 | }, 207 | { 208 | "in": "query", 209 | "name": "boolean_arg", 210 | "required": true, 211 | "type": "boolean" 212 | } 213 | ] 214 | } 215 | } 216 | }, 217 | "host": "localhost:9999", 218 | "basePath": "/sample", 219 | "schemes": [ 220 | "http" 221 | ], 222 | "definitions": { 223 | "standard_response": { 224 | "type": "object", 225 | "required": [ 226 | "raw_response", 227 | "logging_info" 228 | ], 229 | "additionalProperties": false, 230 | "properties": { 231 | "raw_response": { 232 | "type": "string" 233 | }, 234 | "logging_info": { 235 | "type": "object" 236 | } 237 | } 238 | }, 239 | "body_model": { 240 | "type": "object", 241 | "required": [ 242 | "foo" 243 | ], 244 | "additionalProperties": false, 245 | "properties": { 246 | "foo": { 247 | "type": "string" 248 | }, 249 | "bar": { 250 | "type": "string" 251 | } 252 | } 253 | }, 254 | "array_content_model": { 255 | "required": [ 256 | "enum_value" 257 | ], 258 | "properties": { 259 | "enum_value": { 260 | "type": "string", 261 | "enum": [ 262 | "good_enum_value" 263 | ] 264 | } 265 | } 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pyramid_swagger documentation build configuration file, created by 4 | # sphinx-quickstart on Mon May 12 13:42:31 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | from __future__ import absolute_import 14 | 15 | import os 16 | 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | # sys.path.append(os.path.abspath('.')) 22 | 23 | # -- General configuration ----------------------------------------------------- 24 | 25 | # Add any Sphinx extension module names here, as strings. They can be extensions 26 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 27 | extensions = [] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ['_templates'] 31 | 32 | # The suffix of source filenames. 33 | source_suffix = '.rst' 34 | 35 | # The encoding of source files. 36 | #source_encoding = 'utf-8' 37 | 38 | # The master toctree document. 39 | master_doc = 'index' 40 | 41 | # General information about the project. 42 | project = u'pyramid_swagger' 43 | copyright = u'2014, Scott Triglia' 44 | 45 | # The version info for the project you're documenting, acts as replacement for 46 | # |version| and |release|, also used in various other places throughout the 47 | # built documents. 48 | # 49 | # The short X.Y version. 50 | version = '0.1.0' 51 | # The full version, including alpha/beta/rc tags. 52 | release = '0.1.0' 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of documents that shouldn't be included in the build. 65 | #unused_docs = [] 66 | 67 | # List of directories, relative to source directory, that shouldn't be searched 68 | # for source files. 69 | exclude_trees = [] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. Major themes that come with 95 | # Sphinx are currently 'default' and 'sphinxdoc'. 96 | html_theme = 'sphinx_rtd_theme' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | #html_static_path = ['_static'] 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '%b %d, %Y' 130 | 131 | # If true, SmartyPants will be used to convert quotes and dashes to 132 | # typographically correct entities. 133 | #html_use_smartypants = True 134 | 135 | # Custom sidebar templates, maps document names to template names. 136 | #html_sidebars = {} 137 | 138 | # Additional templates that should be rendered to pages, maps page names to 139 | # template names. 140 | #html_additional_pages = {} 141 | 142 | # If false, no module index is generated. 143 | #html_use_modindex = True 144 | 145 | # If false, no index is generated. 146 | #html_use_index = True 147 | 148 | # If true, the index is split into individual pages for each letter. 149 | #html_split_index = False 150 | 151 | # If true, links to the reST sources are added to the pages. 152 | #html_show_sourcelink = True 153 | 154 | # If true, an OpenSearch description file will be output, and all pages will 155 | # contain a tag referring to it. The value of this option must be the 156 | # base URL from which the finished HTML is served. 157 | #html_use_opensearch = '' 158 | 159 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 160 | #html_file_suffix = '' 161 | 162 | # Output file base name for HTML help builder. 163 | htmlhelp_basename = 'pyramid_swaggerdoc' 164 | 165 | 166 | # -- Options for LaTeX output -------------------------------------------------- 167 | 168 | # The paper size ('letter' or 'a4'). 169 | #latex_paper_size = 'letter' 170 | 171 | # The font size ('10pt', '11pt' or '12pt'). 172 | #latex_font_size = '10pt' 173 | 174 | # Grouping the document tree into LaTeX files. List of tuples 175 | # (source start file, target name, title, author, documentclass [howto/manual]). 176 | latex_documents = [ 177 | ('index', 'pyramid_swagger.tex', u'pyramid\\_swagger Documentation', 178 | u'Scott Triglia', 'manual'), 179 | ] 180 | latex_elements = { 181 | # Additional stuff for the LaTeX preamble. See 182 | # https://github.com/rtfd/readthedocs.org/issues/416 183 | 'preamble': "".join(( 184 | '\DeclareUnicodeCharacter{00A0}{ }', # NO-BREAK SPACE 185 | '\DeclareUnicodeCharacter{251C}{+}', # BOX DRAWINGS LIGHT VERTICAL AND RIGHT 186 | '\DeclareUnicodeCharacter{2514}{+}', # BOX DRAWINGS LIGHT UP AND RIGHT 187 | )), 188 | } 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # Additional stuff for the LaTeX preamble. 199 | #latex_preamble = '' 200 | 201 | # Documents to append as an appendix to all manuals. 202 | #latex_appendices = [] 203 | 204 | # If false, no module index is generated. 205 | #latex_use_modindex = True 206 | -------------------------------------------------------------------------------- /tests/acceptance/response20_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Swagger 2.0 response acceptance tests 4 | # 5 | # Based on request_test.py (Swagger 1.2 tests). Differences made it hard for 6 | # a single codebase to exercise both Swagger 1.2 and 2.0 responses. 7 | # 8 | from __future__ import absolute_import 9 | 10 | import pytest 11 | import simplejson 12 | from mock import Mock 13 | from mock import patch 14 | from pyramid.interfaces import IRoute 15 | from pyramid.interfaces import IRoutesMapper 16 | from pyramid.response import Response 17 | from webtest.app import AppError 18 | 19 | from pyramid_swagger.exceptions import ResponseValidationError 20 | from pyramid_swagger.ingest import get_swagger_spec 21 | from pyramid_swagger.tween import validation_tween_factory 22 | from tests.acceptance.request_test import build_test_app 23 | from tests.acceptance.response_test import CustomResponseValidationException 24 | from tests.acceptance.response_test import EnhancedDummyRequest 25 | from tests.acceptance.response_test import get_registry 26 | from tests.acceptance.response_test import validation_ctx_path 27 | 28 | 29 | def _validate_against_tween(request, response=None, path_pattern='/', 30 | **overrides): 31 | """ 32 | Acceptance testing helper for testing the swagger tween with Swagger 2.0 33 | responses. 34 | 35 | :param request: pyramid request 36 | :param response: standard fixture by default 37 | :param path_pattern: Path pattern eg. /foo/{bar} 38 | :param overrides: dict of overrides for `pyramid_swagger` config 39 | """ 40 | def handler(request): 41 | return response or Response() 42 | 43 | settings = dict({ 44 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 45 | 'pyramid_swagger.enable_swagger_spec_validation': False, 46 | 'pyramid_swagger.enable_response_validation': True, 47 | 'pyramid_swagger.swagger_versions': ['2.0']}, 48 | **overrides 49 | ) 50 | 51 | spec = get_swagger_spec(settings) 52 | settings['pyramid_swagger.schema12'] = None 53 | settings['pyramid_swagger.schema20'] = spec 54 | 55 | registry = get_registry(settings) 56 | 57 | # This is a little messy because the current flow of execution doesn't 58 | # set up the route_info in pyramid. Have to mock out the `route_info` 59 | # so that usages in the tween meet expectations. Holler if you know a 60 | # better way to do this! 61 | op = spec.get_op_for_request(request.method, path_pattern) 62 | mock_route = Mock(spec=IRoute) 63 | mock_route.name = path_pattern 64 | mock_route_info = {'match': request.matchdict, 'route': mock_route} 65 | mock_route_mapper = Mock(spec=IRoutesMapper, return_value=mock_route_info) 66 | with patch('pyramid_swagger.tween.get_op_for_request', return_value=op): 67 | with patch('pyramid.registry.Registry.queryUtility', 68 | return_value=mock_route_mapper): 69 | validation_tween_factory(handler, registry)(request) 70 | 71 | 72 | def test_response_validation_enabled_by_default(): 73 | request = EnhancedDummyRequest( 74 | method='GET', 75 | path='/sample/path_arg1/resource', 76 | params={'required_arg': 'test'}, 77 | matchdict={'path_arg': 'path_arg1'}, 78 | ) 79 | # Omit the logging_info key from the response. If response validation 80 | # occurs, we'll fail it. 81 | response = Response( 82 | body=simplejson.dumps({'raw_response': 'foo'}), 83 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 84 | ) 85 | with pytest.raises(ResponseValidationError): 86 | _validate_against_tween( 87 | request, 88 | response=response, 89 | path_pattern='/sample/{path_arg}/resource') 90 | 91 | 92 | def test_500_when_response_is_missing_required_field(): 93 | 94 | request = EnhancedDummyRequest( 95 | method='GET', 96 | path='/sample/path_arg1/resource', 97 | params={'required_arg': 'test'}, 98 | matchdict={'path_arg': 'path_arg1'}, 99 | ) 100 | 101 | # Omit the logging_info key from the response to induce failure 102 | response = Response( 103 | body=simplejson.dumps({'raw_response': 'foo'}), 104 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 105 | ) 106 | 107 | with pytest.raises(ResponseValidationError) as excinfo: 108 | _validate_against_tween( 109 | request, 110 | response=response, 111 | path_pattern='/sample/{path_arg}/resource') 112 | 113 | assert "'logging_info' is a required property" in str(excinfo.value) 114 | 115 | 116 | def test_200_when_response_is_void_with_none_response(): 117 | request = EnhancedDummyRequest( 118 | method='GET', 119 | path='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}', 120 | params={'required_arg': 'test'}, 121 | matchdict={'int_arg': '1', 'float_arg': '2.0', 'boolean_arg': 'true'}, 122 | ) 123 | response = Response( 124 | body=simplejson.dumps(None), 125 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 126 | ) 127 | _validate_against_tween( 128 | request, 129 | response=response, 130 | path_pattern='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}') 131 | 132 | 133 | def test_200_when_response_is_void_with_empty_response(): 134 | request = EnhancedDummyRequest( 135 | method='GET', 136 | path='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}', 137 | params={'required_arg': 'test'}, 138 | matchdict={'int_arg': '1', 'float_arg': '2.0', 'boolean_arg': 'true'}, 139 | ) 140 | response = Response(body='{}') 141 | _validate_against_tween( 142 | request, 143 | response=response, 144 | path_pattern='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}') 145 | 146 | 147 | def test_500_when_response_arg_is_wrong_type(): 148 | request = EnhancedDummyRequest( 149 | method='GET', 150 | path='/sample/path_arg1/resource', 151 | params={'required_arg': 'test'}, 152 | matchdict={'path_arg': 'path_arg1'}, 153 | ) 154 | response = Response( 155 | body=simplejson.dumps({ 156 | 'raw_response': 1.0, # <-- is supposed to be a string 157 | 'logging_info': {'foo': 'bar'} 158 | }), 159 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 160 | ) 161 | with pytest.raises(ResponseValidationError) as excinfo: 162 | _validate_against_tween( 163 | request, 164 | response=response, 165 | path_pattern='/sample/{path_arg}/resource') 166 | 167 | assert "1.0 is not of type " in str(excinfo.value) 168 | 169 | 170 | def test_500_for_bad_validated_array_response(): 171 | request = EnhancedDummyRequest( 172 | method='GET', 173 | path='/sample_array_response', 174 | ) 175 | response = Response( 176 | body=simplejson.dumps([{"enum_value": "bad_enum_value"}]), 177 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 178 | ) 179 | with pytest.raises(ResponseValidationError) as excinfo: 180 | _validate_against_tween( 181 | request, 182 | response=response, 183 | path_pattern='/sample_array_response') 184 | 185 | assert "'bad_enum_value' is not one of " in \ 186 | str(excinfo.value) 187 | 188 | 189 | def test_response_not_validated_if_route_in_response_validation_exclude_routes(): 190 | request = EnhancedDummyRequest( 191 | method='GET', 192 | path='/sample_array_response', 193 | ) 194 | response = Response( 195 | body='{}', 196 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 197 | ) 198 | 199 | _validate_against_tween( 200 | request, 201 | response=response, 202 | path_pattern='/sample_array_response', 203 | # the route name is configured to be the same as the path_pattern in `_validate_against_tween` 204 | **{'pyramid_swagger.response_validation_exclude_routes': {'/sample_array_response'}}) 205 | 206 | 207 | def test_200_for_good_validated_array_response(): 208 | request = EnhancedDummyRequest( 209 | method='GET', 210 | path='/sample_array_response', 211 | ) 212 | response = Response( 213 | body=simplejson.dumps([{"enum_value": "good_enum_value"}]), 214 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 215 | ) 216 | 217 | _validate_against_tween( 218 | request, 219 | response=response, 220 | path_pattern='/sample_array_response') 221 | 222 | 223 | def test_200_for_normal_response_validation(): 224 | assert build_test_app( 225 | swagger_versions=['2.0'], 226 | **{'pyramid_swagger.enable_response_validation': True} 227 | ).post_json( 228 | '/sample', {'foo': 'test', 'bar': 'test'}, 229 | ).status_code == 200 230 | 231 | 232 | def test_app_error_if_path_not_in_spec_and_path_validation_disabled(): 233 | """If path missing and validation is disabled we want to let something else 234 | handle the error. TestApp throws an AppError, but Pyramid would throw a 235 | HTTPNotFound exception. 236 | """ 237 | with pytest.raises(AppError): 238 | assert build_test_app( 239 | swagger_versions=['2.0'], 240 | **{'pyramid_swagger.enable_path_validation': False} 241 | ).get('/this/path/doesnt/exist') 242 | 243 | 244 | def test_response_validation_context(): 245 | request = EnhancedDummyRequest( 246 | method='GET', 247 | path='/sample/path_arg1/resource', 248 | params={'required_arg': 'test'}, 249 | matchdict={'path_arg': 'path_arg1'}, 250 | ) 251 | # Omit the logging_info key from the response. 252 | response = Response( 253 | body=simplejson.dumps({'raw_response': 'foo'}), 254 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 255 | ) 256 | 257 | with pytest.raises(CustomResponseValidationException): 258 | _validate_against_tween( 259 | request, 260 | response=response, 261 | path_pattern='/sample/{path_arg}/resource', 262 | **{'pyramid_swagger.validation_context_path': validation_ctx_path} 263 | ) 264 | -------------------------------------------------------------------------------- /pyramid_swagger/ingest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import unicode_literals 4 | 5 | import glob 6 | import os.path 7 | 8 | import simplejson 9 | from bravado_core.spec import build_http_handlers 10 | from bravado_core.spec import Spec 11 | from six import iteritems 12 | from six.moves.urllib import parse as urlparse 13 | from six.moves.urllib.request import pathname2url 14 | 15 | from pyramid_swagger.api import build_swagger_12_endpoints 16 | from pyramid_swagger.load_schema import load_schema 17 | from pyramid_swagger.model import SwaggerSchema 18 | from pyramid_swagger.spec import API_DOCS_FILENAME 19 | from pyramid_swagger.spec import validate_swagger_schema 20 | 21 | 22 | # Prefix of configs that will be passed to the underlying bravado-core instance 23 | BRAVADO_CORE_CONFIG_PREFIX = 'bravado_core.' 24 | 25 | 26 | class ResourceListingNotFoundError(Exception): 27 | pass 28 | 29 | 30 | class ApiDeclarationNotFoundError(Exception): 31 | pass 32 | 33 | 34 | class ResourceListingGenerationError(Exception): 35 | pass 36 | 37 | 38 | def find_resource_names(api_docs_json): 39 | return [api['path'].lstrip('/') for api in api_docs_json['apis']] 40 | 41 | 42 | def find_resource_paths(schema_dir): 43 | """The inverse of :func:`find_resource_names` used to generate 44 | a resource listing from a directory of swagger api docs. 45 | """ 46 | def not_api_doc_file(filename): 47 | return not filename.endswith(API_DOCS_FILENAME) 48 | 49 | def not_swagger_dot_json(filename): 50 | # Exclude a Swagger 2.0 schema file if it happens to exist. 51 | return not os.path.basename(filename) == 'swagger.json' 52 | 53 | def filename_to_path(filename): 54 | filename, _ext = os.path.splitext(os.path.basename(filename)) 55 | return '/' + filename 56 | 57 | filenames = glob.glob('{0}/*.json'.format(schema_dir)) 58 | return map(filename_to_path, 59 | filter(not_swagger_dot_json, 60 | filter(not_api_doc_file, sorted(filenames)))) 61 | 62 | 63 | def build_schema_mapping(schema_dir, resource_listing): 64 | """Discovers schema file locations and relations. 65 | 66 | :param schema_dir: the directory schema files live inside 67 | :type schema_dir: string 68 | :param resource_listing: A swagger resource listing 69 | :type resource_listing: dict 70 | :returns: a mapping from resource name to file path 71 | :rtype: dict 72 | """ 73 | def resource_name_to_filepath(name): 74 | return os.path.join(schema_dir, '{0}.json'.format(name)) 75 | 76 | return dict( 77 | (resource, resource_name_to_filepath(resource)) 78 | for resource in find_resource_names(resource_listing) 79 | ) 80 | 81 | 82 | def _load_resource_listing(resource_listing): 83 | """Load the resource listing from file, handling errors. 84 | 85 | :param resource_listing: path to the api-docs resource listing file 86 | :type resource_listing: string 87 | :returns: contents of the resource listing file 88 | :rtype: dict 89 | """ 90 | try: 91 | with open(resource_listing) as resource_listing_file: 92 | return simplejson.load(resource_listing_file) 93 | # If not found, raise a more user-friendly error. 94 | except IOError: 95 | raise ResourceListingNotFoundError( 96 | 'No resource listing found at {0}. Note that your json file ' 97 | 'must be named {1}'.format(resource_listing, API_DOCS_FILENAME) 98 | ) 99 | 100 | 101 | def generate_resource_listing(schema_dir, listing_base): 102 | if 'apis' in listing_base: 103 | raise ResourceListingGenerationError( 104 | "{0}/{1} has an `apis` listing. Generating a listing would " 105 | "override this listing.".format(schema_dir, API_DOCS_FILENAME)) 106 | 107 | return dict( 108 | listing_base, 109 | apis=[{'path': path} for path in find_resource_paths(schema_dir)] 110 | ) 111 | 112 | 113 | def get_resource_listing(schema_dir, should_generate_resource_listing): 114 | """Return the resource listing document. 115 | 116 | :param schema_dir: the directory which contains swagger spec files 117 | :type schema_dir: string 118 | :param should_generate_resource_listing: when True a resource listing will 119 | be generated from the list of *.json files in the schema_dir. Otherwise 120 | return the contents of the resource listing file 121 | :type should_generate_resource_listing: boolean 122 | :returns: the contents of a resource listing document 123 | """ 124 | listing_filename = os.path.join(schema_dir, API_DOCS_FILENAME) 125 | resource_listing = _load_resource_listing(listing_filename) 126 | 127 | if not should_generate_resource_listing: 128 | return resource_listing 129 | return generate_resource_listing(schema_dir, resource_listing) 130 | 131 | 132 | def compile_swagger_schema(schema_dir, resource_listing): 133 | """Build a SwaggerSchema from various files. 134 | 135 | :param schema_dir: the directory schema files live inside 136 | :type schema_dir: string 137 | :returns: a SwaggerSchema object 138 | """ 139 | mapping = build_schema_mapping(schema_dir, resource_listing) 140 | resource_validators = ingest_resources(mapping, schema_dir) 141 | endpoints = list(build_swagger_12_endpoints(resource_listing, mapping)) 142 | return SwaggerSchema(endpoints, resource_validators) 143 | 144 | 145 | def get_swagger_schema(settings): 146 | """Return a :class:`pyramid_swagger.model.SwaggerSchema` constructed from 147 | the swagger specs in `pyramid_swagger.schema_directory`. If 148 | `pyramid_swagger.enable_swagger_spec_validation` is enabled the schema 149 | will be validated before returning it. 150 | 151 | :param settings: a pyramid registry settings with configuration for 152 | building a swagger schema 153 | :type settings: dict 154 | :returns: a :class:`pyramid_swagger.model.SwaggerSchema` 155 | """ 156 | schema_dir = settings.get('pyramid_swagger.schema_directory', 'api_docs') 157 | resource_listing = get_resource_listing( 158 | schema_dir, 159 | settings.get('pyramid_swagger.generate_resource_listing', False) 160 | ) 161 | 162 | if settings.get('pyramid_swagger.enable_swagger_spec_validation', True): 163 | validate_swagger_schema(schema_dir, resource_listing) 164 | 165 | return compile_swagger_schema(schema_dir, resource_listing) 166 | 167 | 168 | def get_swagger_spec(settings): 169 | """Return a :class:`bravado_core.spec.Spec` constructed from 170 | the swagger specs in `pyramid_swagger.schema_directory`. If 171 | `pyramid_swagger.enable_swagger_spec_validation` is enabled the schema 172 | will be validated before returning it. 173 | 174 | :param settings: a pyramid registry settings with configuration for 175 | building a swagger schema 176 | :type settings: dict 177 | :rtype: :class:`bravado_core.spec.Spec` 178 | """ 179 | schema_dir = settings.get('pyramid_swagger.schema_directory', 'api_docs/') 180 | schema_filename = settings.get('pyramid_swagger.schema_file', 181 | 'swagger.json') 182 | schema_path = os.path.join(schema_dir, schema_filename) 183 | schema_url = urlparse.urljoin('file:', pathname2url(os.path.abspath(schema_path))) 184 | 185 | handlers = build_http_handlers(None) # don't need http_client for file: 186 | file_handler = handlers['file'] 187 | spec_dict = file_handler(schema_url) 188 | 189 | return Spec.from_dict( 190 | spec_dict, 191 | config=create_bravado_core_config(settings), 192 | origin_url=schema_url) 193 | 194 | 195 | def create_bravado_core_config(settings): 196 | """Create a configuration dict for bravado_core based on pyramid_swagger 197 | settings. 198 | 199 | :param settings: pyramid registry settings with configuration for 200 | building a swagger schema 201 | :type settings: dict 202 | :returns: config dict suitable for passing into 203 | bravado_core.spec.Spec.from_dict(..) 204 | :rtype: dict 205 | """ 206 | # Map pyramid_swagger config key -> bravado_core config key 207 | config_keys = { 208 | 'pyramid_swagger.enable_request_validation': 'validate_requests', 209 | 'pyramid_swagger.enable_response_validation': 'validate_responses', 210 | 'pyramid_swagger.enable_swagger_spec_validation': 'validate_swagger_spec', 211 | 'pyramid_swagger.use_models': 'use_models', 212 | 'pyramid_swagger.user_formats': 'formats', 213 | 'pyramid_swagger.include_missing_properties': 'include_missing_properties', 214 | } 215 | 216 | configs = { 217 | 'use_models': False, 218 | } 219 | configs.update({ 220 | bravado_core_key: settings[pyramid_swagger_key] 221 | for pyramid_swagger_key, bravado_core_key in iteritems(config_keys) 222 | if pyramid_swagger_key in settings 223 | }) 224 | configs.update({ 225 | key.replace(BRAVADO_CORE_CONFIG_PREFIX, ''): value 226 | for key, value in iteritems(settings) 227 | if key.startswith(BRAVADO_CORE_CONFIG_PREFIX) 228 | }) 229 | 230 | return configs 231 | 232 | 233 | def ingest_resources(mapping, schema_dir): 234 | """Consume the Swagger schemas and produce a queryable datastructure. 235 | 236 | :param mapping: Map from resource name to filepath of its api declaration 237 | :type mapping: dict 238 | :param schema_dir: the directory schema files live inside 239 | :type schema_dir: string 240 | :returns: A list of mapping from :class:`RequestMatcher` to 241 | :class:`ValidatorMap` 242 | """ 243 | ingested_resources = [] 244 | for name, filepath in iteritems(mapping): 245 | try: 246 | ingested_resources.append(load_schema(filepath)) 247 | # If we have trouble reading any files, raise a more user-friendly 248 | # error. 249 | except IOError: 250 | raise ApiDeclarationNotFoundError( 251 | 'No api declaration found at {0}. Attempted to load the `{1}` ' 252 | 'resource relative to the schema_directory `{2}`. Perhaps ' 253 | 'your resource name and API declaration file do not ' 254 | 'match?'.format(filepath, name, schema_dir) 255 | ) 256 | return ingested_resources 257 | -------------------------------------------------------------------------------- /tests/acceptance/request_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | import datetime 5 | from contextlib import contextmanager 6 | 7 | import pytest 8 | import simplejson 9 | from pyramid.httpexceptions import exception_response 10 | from webtest.utils import NoDefault 11 | 12 | from pyramid_swagger import exceptions 13 | 14 | 15 | def build_test_app(swagger_versions, **overrides): 16 | """Fixture for setting up a test test_app with particular settings.""" 17 | from tests.acceptance.app import main 18 | from webtest import TestApp as App 19 | settings = dict({ 20 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/', 21 | 'pyramid_swagger.enable_request_validation': True, 22 | 'pyramid_swagger.enable_response_validation': False, 23 | 'pyramid_swagger.enable_swagger_spec_validation': False, 24 | 'pyramid_swagger.swagger_versions': swagger_versions}, 25 | **overrides 26 | ) 27 | 28 | return App(main({}, **settings)) 29 | 30 | 31 | # Parameterize pyramid_swagger.swagger_versions 32 | # Swagger 1.2 tests are broken. Swagger 1.2 is deprecated and thus we have no plans to fix these tests, 33 | # so they have been removed. 34 | @pytest.fixture( 35 | params=[['2.0'], ], 36 | ids=['2.0', ], 37 | ) 38 | def test_app(request): 39 | """Fixture for setting up a test test_app with particular settings.""" 40 | return build_test_app( 41 | swagger_versions=request.param, 42 | ) 43 | 44 | 45 | @contextmanager 46 | def validation_context(request, response=None): 47 | try: 48 | yield 49 | except ( 50 | exceptions.RequestValidationError, 51 | exceptions.ResponseValidationError, 52 | exceptions.PathNotFoundError, 53 | ): 54 | raise exception_response(206) 55 | except Exception: 56 | raise exception_response(400) 57 | 58 | 59 | validation_ctx_path = 'tests.acceptance.request_test.validation_context' 60 | 61 | 62 | def test_echo_date_with_pyramid_swagger_renderer(test_app): 63 | input_object = {'date': datetime.date.today().isoformat()} 64 | 65 | response = test_app.post_json('/echo_date', input_object) 66 | 67 | # If the request is served via Swagger1.2 68 | assert response.status_code == 200 69 | assert response.json == input_object 70 | 71 | 72 | def test_echo_date_with_json_renderer(test_app): 73 | today = datetime.date.today() 74 | input_object = {'date': today.isoformat()} 75 | 76 | exc = None 77 | response = None 78 | try: 79 | response = test_app.post_json('/echo_date_json_renderer', input_object) 80 | except TypeError as exception: 81 | exc = exception 82 | 83 | served_swagger_versions = test_app.app.registry.settings['pyramid_swagger.swagger_versions'] 84 | 85 | if '2.0' in served_swagger_versions: 86 | # If the request is served via Swagger2.0, pyramid_swagger will perform types 87 | # conversions providing a datetime.date object in the pyramid view 88 | assert exc.args == ('{!r} is not JSON serializable'.format(today), ) 89 | else: 90 | # If the request is served via Swagger1.2 there are no implicit type conversions performed by pyramid_swagger 91 | assert response.status_code == 200 92 | assert response.json == input_object 93 | 94 | 95 | @pytest.mark.parametrize( 96 | 'body, expected_length', 97 | [ 98 | [NoDefault, 0], 99 | [{}, 2], 100 | ], 101 | ) 102 | def test_post_endpoint_with_optional_body(test_app, body, expected_length): 103 | assert test_app.post_json('/post_endpoint_with_optional_body', body).json == expected_length 104 | 105 | 106 | def test_200_with_form_params(test_app): 107 | assert test_app.post( 108 | '/post_with_form_params', 109 | {'form_param': 42}, 110 | ).status_code == 200 111 | 112 | 113 | def test_200_with_file_upload(test_app): 114 | assert test_app.post( 115 | '/post_with_file_upload', 116 | upload_files=[('photo_file', 'photo.jpg', b'')], 117 | ).status_code == 200 118 | 119 | 120 | def test_400_with_form_params_wrong_type(test_app): 121 | assert test_app.post( 122 | '/post_with_form_params', 123 | {'form_param': "not a number"}, 124 | expect_errors=True, 125 | ).status_code == 400 126 | 127 | 128 | def test_400_if_json_body_for_form_parms(test_app): 129 | assert test_app.post_json( 130 | '/post_with_form_params', 131 | {'form_param': 42}, 132 | expect_errors=True, 133 | ).status_code == 400 134 | 135 | 136 | def test_400_if_required_query_args_absent(test_app): 137 | assert test_app.get( 138 | '/sample/path_arg1/resource', 139 | expect_errors=True, 140 | ).status_code == 400 141 | 142 | 143 | def test_200_if_optional_query_args_absent(test_app): 144 | assert test_app.get( 145 | '/sample/path_arg1/resource', 146 | params={'required_arg': 'test'}, # no `optional_arg` arg 147 | ).status_code == 200 148 | 149 | 150 | def test_200_if_request_arg_is_wrong_type(test_app): 151 | assert test_app.get( 152 | '/sample/path_arg1/resource', 153 | params={'required_arg': 1.0}, 154 | ).status_code == 200 155 | 156 | 157 | def test_200_if_request_arg_types_are_not_strings(test_app): 158 | assert test_app.get( 159 | '/get_with_non_string_query_args', 160 | params={ 161 | 'int_arg': '5', 162 | 'float_arg': '3.14', 163 | 'boolean_arg': 'true', 164 | }, 165 | ).status_code == 200 166 | 167 | 168 | def test_404_if_path_not_in_swagger(test_app): 169 | assert test_app.get( 170 | '/undefined/path', 171 | expect_errors=True, 172 | ).status_code == 404 173 | 174 | 175 | def test_200_skip_validation_with_excluded_path(): 176 | app = build_test_app( 177 | swagger_versions=['2.0'], 178 | **{'pyramid_swagger.exclude_paths': [r'^/undefined/path']} 179 | ) 180 | assert app.get('/undefined/path').status_code == 200 181 | 182 | 183 | def test_400_if_request_arg_is_wrong_type_but_not_castable(test_app): 184 | assert test_app.get( 185 | '/get_with_non_string_query_args', 186 | params={'float_arg': 'foobar'}, 187 | expect_errors=True, 188 | ).status_code == 400 189 | 190 | 191 | def test_400_if_path_arg_not_valid_enum(test_app): 192 | assert test_app.get( 193 | '/sample/invalid_arg/resource', 194 | params={'required_arg': 'test'}, 195 | expect_errors=True, 196 | ).status_code == 400 197 | 198 | 199 | def test_200_if_path_arg_is_wrong_type_but_castable(test_app): 200 | assert test_app.get( 201 | '/sample/nonstring/3/1.4/false', 202 | ).status_code == 200 203 | 204 | 205 | def test_400_if_required_body_is_missing(test_app): 206 | assert test_app.post_json( 207 | '/sample', 208 | {}, 209 | expect_errors=True, 210 | ).status_code == 400 211 | 212 | 213 | def test_200_on_json_body_without_contenttype_header(test_app): 214 | """See https://github.com/striglia/pyramid_swagger/issues/49.""" 215 | # We use .post to avoid sending a Content Type of application/json. 216 | assert test_app.post( 217 | '/sample?optional_string=bar', 218 | simplejson.dumps({'foo': 'test'}), 219 | {'Content-Type': ''}, 220 | ).status_code == 200 221 | 222 | 223 | def test_400_if_body_has_missing_required_arg(test_app): 224 | assert test_app.post_json( 225 | '/sample', 226 | {'bar': 'test'}, 227 | expect_errors=True, 228 | ).status_code == 400 229 | 230 | 231 | def test_200_if_body_has_missing_optional_arg(test_app): 232 | assert test_app.post_json( 233 | '/sample', 234 | {'foo': 'test'}, 235 | ).status_code == 200 236 | 237 | 238 | def test_200_if_required_body_is_model(test_app): 239 | assert test_app.post_json( 240 | '/sample', 241 | {'foo': 'test', 'bar': 'test'}, 242 | ).status_code == 200 243 | 244 | 245 | def test_200_if_required_body_is_primitives(test_app): 246 | assert test_app.post_json( 247 | '/post_with_primitive_body', 248 | ['foo', 'bar'], 249 | ).status_code == 200 250 | 251 | 252 | def test_400_if_extra_body_args(test_app): 253 | assert test_app.post_json( 254 | '/sample', 255 | {'foo': 'test', 'bar': 'test', 'made_up_argument': 1}, 256 | expect_errors=True, 257 | ).status_code == 400 258 | 259 | 260 | def test_400_if_extra_query_args(test_app): 261 | assert test_app.get( 262 | '/sample/path_arg1/resource?made_up_argument=1', 263 | expect_errors=True, 264 | ).status_code == 400 265 | 266 | 267 | def test_400_if_missing_required_header(test_app): 268 | assert test_app.get( 269 | '/sample/header', 270 | expect_errors=True, 271 | ).status_code == 400 272 | 273 | 274 | def test_200_with_required_header(test_app): 275 | response = test_app.get( 276 | '/sample/header', 277 | headers={'X-Force': 'True'}, 278 | expect_errors=True, 279 | ) 280 | assert response.status_code == 200 281 | 282 | 283 | def test_200_skip_validation_when_disabled(): 284 | # calling endpoint with required args missing 285 | overrides = { 286 | 'pyramid_swagger.enable_request_validation': False, 287 | 'skip_swagger_data_assert': True, 288 | } 289 | app = build_test_app( 290 | swagger_versions=['2.0'], 291 | **overrides 292 | ) 293 | response = app.get('/get_with_non_string_query_args', params={}) 294 | assert response.status_code == 200 295 | 296 | 297 | def test_path_validation_context(): 298 | app = build_test_app( 299 | swagger_versions=['2.0'], 300 | **{'pyramid_swagger.validation_context_path': validation_ctx_path} 301 | ) 302 | assert app.get('/does_not_exist').status_code == 206 303 | 304 | 305 | def test_request_validation_context(): 306 | app = build_test_app( 307 | swagger_versions=['2.0'], 308 | **{'pyramid_swagger.validation_context_path': validation_ctx_path}) 309 | response = app.get('/get_with_non_string_query_args', params={}) 310 | assert response.status_code == 206 311 | 312 | 313 | def test_request_to_authenticated_endpoint_without_authentication(): 314 | app = build_test_app(swagger_versions=['2.0']) 315 | response = app.get( 316 | '/sample/authentication', 317 | expect_errors=True, 318 | ) 319 | assert response.status_code == 401 320 | 321 | 322 | def test_request_to_endpoint_with_no_response_schema(): 323 | app = build_test_app(swagger_versions=['2.0']) 324 | response = app.get('/sample/no_response_schema') 325 | assert response.status_code == 200 326 | -------------------------------------------------------------------------------- /pyramid_swagger/load_schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Module to load swagger specs and build efficient data structures for querying 4 | them during request validation. 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import unicode_literals 8 | 9 | from collections import namedtuple 10 | from copy import deepcopy 11 | 12 | import jsonschema 13 | import simplejson 14 | from jsonschema import RefResolver 15 | from jsonschema import validators 16 | from jsonschema.exceptions import ValidationError 17 | from jsonschema.validators import Draft3Validator 18 | from jsonschema.validators import Draft4Validator 19 | from six import iteritems 20 | 21 | from pyramid_swagger.model import partial_path_match 22 | 23 | 24 | EXTENDED_TYPES = { 25 | 'number': (float,), 26 | 'float': (float,), 27 | 'int': (int,), 28 | } 29 | 30 | 31 | _draft3_type_validator = Draft3Validator.VALIDATORS['type'] 32 | _draft4_type_validator = Draft4Validator.VALIDATORS['type'] 33 | _draft4_required_validator = Draft4Validator.VALIDATORS['required'] 34 | _ref_validator = Draft4Validator.VALIDATORS['$ref'] 35 | 36 | 37 | def build_param_schema(schema, param_type): 38 | """Turn a swagger endpoint schema into an equivalent one to validate our 39 | request. 40 | 41 | As an example, this would take this swagger schema: 42 | { 43 | "paramType": "query", 44 | "name": "query", 45 | "description": "Location to query", 46 | "type": "string", 47 | "required": true 48 | } 49 | To this jsonschema: 50 | { 51 | "type": "object", 52 | "additionalProperties": "False", 53 | "properties:": { 54 | "description": "Location to query", 55 | "type": "string", 56 | "required": true 57 | } 58 | } 59 | Which we can then validate against a JSON object we construct from the 60 | pyramid request. 61 | """ 62 | properties = filter_params_by_type(schema, param_type) 63 | if not properties: 64 | return 65 | 66 | # Generate a jsonschema that describes the set of all query parameters. We 67 | # can then validate this against dict(request.params). 68 | return { 69 | 'type': 'object', 70 | 'properties': dict((p['name'], p) for p in properties), 71 | # Allow extra headers. Most HTTP requests will have headers which 72 | # are outside the scope of the spec (like `Host`, or `User-Agent`) 73 | 'additionalProperties': param_type == 'header', 74 | } 75 | 76 | 77 | def filter_params_by_type(schema, param_type): 78 | return [s for s in schema['parameters'] if s['paramType'] == param_type] 79 | 80 | 81 | def extract_body_schema(schema): 82 | """Return the body parameter schema from an operation schema.""" 83 | matching_body_schemas = filter_params_by_type(schema, 'body') 84 | # There can be only one body param 85 | return matching_body_schemas[0] if matching_body_schemas else None 86 | 87 | 88 | def ignore(_validator, *args): 89 | """A validator which performs no validation. Used to `ignore` some schema 90 | fields during validation. 91 | """ 92 | return 93 | 94 | 95 | def build_swagger_type_validator(models): 96 | def swagger_type_validator(validator, ref, instance, schema): 97 | if ref in models: 98 | return _ref_validator(validator, ref, instance, schema) 99 | else: 100 | return _draft4_type_validator(validator, ref, instance, schema) 101 | 102 | return swagger_type_validator 103 | 104 | 105 | def type_validator(validator, types, instance, schema): 106 | """Swagger 1.2 supports parameters of 'type': 'File'. Skip validation of 107 | the 'type' field in this case. 108 | """ 109 | if schema.get('type') == 'File': 110 | return [] 111 | return _draft3_type_validator(validator, types, instance, schema) 112 | 113 | 114 | def required_validator(validator, req, instance, schema): 115 | """Swagger 1.2 expects `required` to be a bool in the Parameter object, but 116 | a list of properties in a Model object. 117 | """ 118 | if schema.get('paramType'): 119 | if req is True and not instance: 120 | return [ValidationError("%s is required" % schema['name'])] 121 | return [] 122 | return _draft4_required_validator(validator, req, instance, schema) 123 | 124 | 125 | def get_body_validator(models): 126 | """Returns a validator for the request body, based on a 127 | :class:`jsonschema.validators.Draft4Validator`, with extra validations 128 | added for swaggers extensions to jsonschema. 129 | 130 | :param models: a mapping of reference to models 131 | :returns: a :class:`jsonschema.validators.Validator` which can validate 132 | the request body. 133 | """ 134 | return validators.extend( 135 | Draft4Validator, 136 | { 137 | 'paramType': ignore, 138 | 'name': ignore, 139 | 'type': build_swagger_type_validator(models), 140 | 'required': required_validator, 141 | } 142 | ) 143 | 144 | 145 | Swagger12ParamValidator = validators.extend( 146 | Draft3Validator, 147 | { 148 | 'paramType': ignore, 149 | 'name': ignore, 150 | 'type': type_validator, 151 | } 152 | ) 153 | 154 | 155 | class ValidatorMap( 156 | namedtuple('_VMap', 'query path form headers body response') 157 | ): 158 | """ 159 | A data object with validators for each part of the request and response 160 | objects. Each field is a :class:`SchemaValidator`. 161 | """ 162 | __slots__ = () 163 | 164 | @classmethod 165 | def from_operation(cls, operation, models, resolver): 166 | args = [] 167 | for schema, validator in [ 168 | (build_param_schema(operation, 'query'), Swagger12ParamValidator), 169 | (build_param_schema(operation, 'path'), Swagger12ParamValidator), 170 | (build_param_schema(operation, 'form'), Swagger12ParamValidator), 171 | (build_param_schema(operation, 'header'), Swagger12ParamValidator), 172 | (extract_body_schema(operation), get_body_validator(models)), 173 | (extract_response_body_schema(operation, models), 174 | Draft4Validator), 175 | ]: 176 | args.append(SchemaValidator.from_schema( 177 | schema, 178 | resolver, 179 | validator)) 180 | 181 | return cls(*args) 182 | 183 | 184 | class SchemaValidator(object): 185 | """A Validator used by :mod:`pyramid_swagger.tween` to validate a 186 | field from the request or response. 187 | 188 | :param schema: a :class:`dict` jsonschema that was used by the 189 | validator 190 | :param validator: a Validator which a func:`validate` method 191 | for validating a field from a request or response. This 192 | will often be a :class:`jsonschema.validator.Validator`. 193 | """ 194 | 195 | def __init__(self, schema, validator): 196 | self.schema = schema 197 | self.validator = validator 198 | 199 | @classmethod 200 | def from_schema(cls, schema, resolver, validator_class): 201 | type_checker = deepcopy(validator_class.TYPE_CHECKER) 202 | type_checker.redefine_many({ 203 | type_name: lambda checker, value: all(check(value) for check in checks) 204 | for type_name, checks in iteritems(EXTENDED_TYPES) 205 | }) 206 | extended_validator_class = jsonschema.validators.extend( 207 | validator_class, 208 | type_checker=type_checker, 209 | ) 210 | return cls( 211 | schema, 212 | extended_validator_class(schema, resolver=resolver)) 213 | 214 | def validate(self, values): 215 | """Validate a :class:`dict` of values. If `self.schema` is falsy this 216 | is a noop. 217 | """ 218 | if not self.schema or (values is None and not self.schema.get('required', False)): 219 | return 220 | self.validator.validate(values) 221 | 222 | 223 | def build_request_to_validator_map(schema, resolver): 224 | """Build a mapping from :class:`RequestMatcher` to :class:`ValidatorMap` 225 | for each operation in the API spec. This mapping may be used to retrieve 226 | the appropriate validators for a request. 227 | """ 228 | schema_models = schema.get('models', {}) 229 | return dict( 230 | ( 231 | RequestMatcher(api['path'], operation['method']), 232 | ValidatorMap.from_operation(operation, schema_models, resolver) 233 | ) 234 | for api in schema['apis'] 235 | for operation in api['operations'] 236 | ) 237 | 238 | 239 | class RequestMatcher(object): 240 | """Match a :class:`pyramid.request.Request` to a swagger Operation""" 241 | 242 | def __init__(self, path, method): 243 | self.path = path 244 | self.method = method 245 | 246 | def matches(self, request): 247 | """ 248 | :param request: a :class:`pyramid.request.Request` 249 | :returns: True if this matcher matches the request, False otherwise 250 | """ 251 | return partial_path_match(request.path_info, self.path) and request.method == self.method 252 | 253 | 254 | def extract_response_body_schema(operation, schema_models): 255 | if operation['type'] in schema_models: 256 | return extract_validatable_type(operation['type'], schema_models) 257 | 258 | acceptable_fields = ( 259 | 'type', '$ref', 'format', 'defaultValue', 'enum', 'minimum', 260 | 'maximum', 'items', 'uniqueItems' 261 | ) 262 | 263 | return dict( 264 | (field, operation[field]) 265 | for field in acceptable_fields 266 | if field in operation 267 | ) 268 | 269 | 270 | def extract_validatable_type(type_name, models): 271 | """Returns a jsonschema-compatible typename from the Swagger type. 272 | 273 | This is necessary because for our Swagger specs to be compatible with 274 | swagger-ui, they must not use a $ref to internal models. 275 | 276 | :returns: A key-value that jsonschema can validate. Key will be either 277 | 'type' or '$ref' as is appropriate. 278 | :rtype: dict 279 | """ 280 | if type_name in models: 281 | return {'$ref': type_name} 282 | else: 283 | return {'type': type_name} 284 | 285 | 286 | def load_schema(schema_path): 287 | """Prepare the api specification for request and response validation. 288 | 289 | :returns: a mapping from :class:`RequestMatcher` to :class:`ValidatorMap` 290 | for every operation in the api specification. 291 | :rtype: dict 292 | """ 293 | with open(schema_path, 'r') as schema_file: 294 | schema = simplejson.load(schema_file) 295 | resolver = RefResolver('', '', schema.get('models', {})) 296 | return build_request_to_validator_map(schema, resolver) 297 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 2.9.0 (2024-05-08) 4 | ++++++++++++++++++++++++++ 5 | * Add response_validation_exclude_routes support (see #253) 6 | 7 | 2.8.0 (2024-05-03) 8 | ++++++++++++++++++++++++++ 9 | * Ensure http 401 in case of missing security (see #239) 10 | * Fix pyramid swagger renderer if missing schema (see #242) 11 | * Reduce usage of deprecated methods (see #241) 12 | * Minor fixes (see #243, #244 and #246) 13 | * Update tox to py38 and py310 and skip swagger 1.2 tests (see #249) 14 | 15 | 2.7.0 (2019-05-07) 16 | ++++++++++++++++++++++++++ 17 | * Remove not needed deprecation warnings (see #238) 18 | * Make ``pyramid_swagger`` compatible with ``jsonschema>3`` (see #327) 19 | 20 | 2.6.2 (2018-07-02) 21 | ++++++++++++++++++++++++++ 22 | * Fix bug that prevents library usage on Windows Platform (see #234) 23 | 24 | 2.6.1 (2018-05-24) 25 | ++++++++++++++++++++++++++ 26 | * Fix operation extraction in `PyramidSwaggerRendererFactory` in case of Swagger1.2 endpoint (see #230) 27 | * Fix request body extraction if body is not set. (see #231) 28 | 29 | 2.6.0 (2017-11-14) 30 | ++++++++++++++++++++++++++ 31 | * Support setting bravado-core config values by prefixing them with ``bravado_core.`` in the pyramid_swagger config (see #221) 32 | * Support raw_bytes response attribute, required for msgpack wire format support in outgoing responses (see #222) 33 | 34 | 2.5.0 (2017-10-26) 35 | ++++++++++++++++++++++++++ 36 | * Support `include_missing_properties` bravado-core flag in pyramid configuration 37 | * Outsource flattening logic to bravado-core library. 38 | * Expose bravado-core ``operation`` in request object 39 | * Add ``pyramid_renderer`` and ``PyramidSwaggerRendererFactory`` 40 | 41 | 2.4.1 (2017-06-14) 42 | ++++++++++++++++++++++++++ 43 | * Bugfix: add a quick fix to prevent resolve_refs from making empty json keys on external refs (see #206) 44 | 45 | 2.4.0 (2017-05-30) 46 | ++++++++++++++++++++++++++ 47 | * Bugfix: prevent resolve_refs from resolution failures when flattening specs with recursive $refs (see #204) 48 | * Allow serving of api_docs from paths besides /api_docs (see #187) 49 | * Support virtual hosting via SCRIPT_NAME (see #201 and https://www.python.org/dev/peps/pep-0333/) 50 | 51 | 2.3.2 (2017-04-10) 52 | ++++++++++++++++++ 53 | * Fix reading configuration values from INI files (see #182, #200) 54 | 55 | 2.3.1 (2017-03-27) 56 | ++++++++++++++++++ 57 | * Fix validation context for swagger 2.0 requests 58 | * Added docs for validation context 59 | * Preserved original exception when reraising for validation context exceptions 60 | * Remove support for Python 2.6, newer Pyramid versions don't support it either 61 | * Fix issue with missing content type when using webob >= 1.7 (see #185) 62 | 63 | 2.3.0 (2016-09-27) 64 | ++++++++++++++++++ 65 | * Fix installation with Python 3 on systems using a POSIX/ASCII locale. 66 | 67 | 2.3.0-rc3 (2016-06-28) 68 | ++++++++++++++++++++++ 69 | * Adds ``dereference_served_schema`` config flag to force served spec to be a 70 | single file. Useful for avoiding mixed-spec inconsistencies when running 71 | multiple versions of your service simultaneously. 72 | 73 | 2.3.0-rc2 (2016-05-09) 74 | ++++++++++++++++++++++ 75 | * Add ability for a single spec to serve YAML or JSON to clients 76 | * Support multi-file local specs, serving them over multiple HTTP endpoints 77 | * Improve Swagger validation messages when Pyramid cannot find your route (see #163) 78 | * Bugfix: responses with headers in the spec no longer break request validation (see #159) 79 | 80 | 2.3.0-rc1 (2016-03-21) 81 | ++++++++++++++++++++++ 82 | * Support for YAML spec files 83 | * Bugfix: remove extraneous x-scope in digested spec (see https://github.com/Yelp/bravado-core/issues/78) 84 | 85 | 2.2.3 (2016-02-09) 86 | ++++++++++++++++++++++ 87 | * Restore testing of py3x versions 88 | * Support pyramid 1.6 and beyond. 89 | * Support specification of routes using route_prefix 90 | 91 | 2.2.2 (2015-10-12) 92 | ++++++++++++++++++++++ 93 | * Upgrade to bravado-core 3.0.0, which includes a change in the way user-defined formats are registered. See the `Bravado 3.0.0 changelog entry`_ for more detail. 94 | 95 | .. _Bravado 3.0.0 changelog entry: http://github.com/Yelp/bravado-core/blob/master/CHANGELOG.rst 96 | 97 | 98 | 2.2.1 (2015-08-20) 99 | ++++++++++++++++++++++ 100 | * No longer attempts to validate error responses, which typically don't follow 101 | the same format as successful responses. (Closes: #121) 102 | 103 | 2.2.0 (2015-08-19) 104 | ++++++++++++++++++++++ 105 | * Added ``prefer_20_routes`` configuration option to ease incremental migrations from v1.2 to 106 | v2.0. (See :ref:`prefer20migration`) 107 | 108 | 2.1.0 (2015-08-14) 109 | ++++++++++++++++++++++ 110 | * Added ``user_formats`` configuration option to provide user-defined formats which can be used for validations 111 | and conversions to wire-python-wire formats. (See :ref:`user-format-label`) 112 | * Added support for relative cross-refs in Swagger v2.0 specs. 113 | 114 | 2.0.0 (2015-06-25) 115 | ++++++++++++++++++++++ 116 | * Added ``use_models`` configuration option for Swagger 2.0 backwards compatibility with existing pyramid views 117 | 118 | 2.0.0-rc2 (2015-05-26) 119 | ++++++++++++++++++++++ 120 | * Upgraded bravado-core to 1.0.0-rc1 so basePath is used when matching a request to an operation 121 | * Updates for refactored SwaggerError exception hierarchy in bravado-core 122 | * Fixed file uploads that use Content-Type: multipart/form-data 123 | 124 | 2.0.0-rc1 (2015-05-13) 125 | ++++++++++++++++++++++ 126 | 127 | **Backwards Incompatible** 128 | 129 | * Support for Swagger 2.0 - See `Migrating to Swagger 2.0`_ 130 | 131 | .. _Migrating to Swagger 2.0: http://pyramid-swagger.readthedocs.org/en/latest/migrating_to_swagger_20.html 132 | 133 | 1.5.0 (2015-05-12) 134 | ++++++++++++++++++++++ 135 | 136 | * Now using swagger_spec_validator package for spec validation. Should be far 137 | more robust than the previous implementation. 138 | 139 | 1.5.0-rc2 (2015-04-1) 140 | ++++++++++++++++++++++ 141 | 142 | * Form-encoded bodies are now validated correctly. 143 | * Fixed bug in `required` swagger attribute handling. 144 | 145 | 1.5.0-rc1 (2015-03-30) 146 | ++++++++++++++++++++++ 147 | 148 | * Added ``enable_api_docs_views`` configuration option so /api-docs 149 | auto-registration can be disabled in situations where users want to serve 150 | the Swagger spec in a nonstandard way. 151 | * Added ``exclude_routes`` configuration option. Allows a blacklist of Pyramid 152 | routes which will be ignored for validation purposes. 153 | * Added ``generate_resource_listing`` configuration option to allow 154 | pyramid_swagger to generate the ``apis`` section of the resource listing. 155 | * Bug fix for issues relating to ``void`` responses (See `Issue 79`_) 156 | * Added support for header validation. 157 | * Make casted values from the request available through 158 | ``request.swagger_data`` 159 | 160 | .. _Issue 79: https://github.com/striglia/pyramid_swagger/issues/79 161 | 162 | 1.4.0 (2015-01-27) 163 | ++++++++++++++++++ 164 | 165 | * Added ``validation_context_path`` setting which allows the user to specify a 166 | path to a contextmanager to custom handle request/response validation 167 | exceptions. 168 | 169 | 1.3.0 (2014-12-02) 170 | ++++++++++++++++++ 171 | 172 | * Now throws RequestValidationError and ResponseValidationError instead of 173 | HTTPClientError and HTTPInternalServerError respectively. The new errors 174 | subclass the old ones for backwards compatibility. 175 | 176 | 1.2.0 (2014-10-21) 177 | ++++++++++++++++++ 178 | 179 | * Added ``enable_request_validation`` setting which toggles whether request 180 | content is validated. 181 | * Added ``enable_path_validation`` setting which toggles whether HTTP calls to 182 | endpoints will 400 if the URL is not described in the Swagger schema. If this 183 | flag is disabled and the path is not found, no validation of any kind is 184 | performed by pyramid-swagger. 185 | * Added ``exclude_paths`` setting which duplicates the functionality of 186 | `skip_validation`. `skip_validation` is deprecated and scheduled for removal 187 | in the 2.0.0 release. 188 | * Adds LICENSE file 189 | * Fixes misuse of webtest which could cause ``make test`` to pass while 190 | functionality was broken. 191 | 192 | 1.1.1 (2014-08-26) 193 | ++++++++++++++++++ 194 | 195 | * Fixes bug where response bodies were not validated correctly unless they were 196 | a model or primitive type. 197 | * Fixes bug where POST bodies could be mis-parsed as query arguments. 198 | * Better backwards compatibility warnings in this changelog! 199 | 200 | 1.1.0 (2014-07-14) 201 | ++++++++++++++++++ 202 | 203 | * Swagger schema directory defaults to ``api_docs/`` rather than being a required 204 | configuration line. 205 | * If the resource listing or API declarations are not at the filepaths 206 | expected, readable errors are raised. 207 | * This changelog is now a part of the build documentation and backfilled to the 208 | initial package version. 209 | 210 | 211 | 1.0.0 (2014-07-08) 212 | ++++++++++++++++++ 213 | 214 | **Backwards Incompatible** 215 | 216 | * Initial fully functional release. 217 | * Your service now must supply both a resource listing and all accompanying api 218 | declarations. 219 | * Swagger schemas are automatically served out of ``/api-docs`` by including the 220 | library. 221 | * The api declaration basepath returned by hitting ``/api-docs/foo`` is guaranteed 222 | to be ``Pyramid.request.application_url``. 223 | * Void return types are now checked. 224 | 225 | 226 | 0.5.0 (2014-07-08) 227 | ++++++++++++++++++ 228 | 229 | * Added configurable list of regular expressions to not validate 230 | requests/responses against. 231 | * Vastly improved documentation! Includes a quickstart for those new to the 232 | library. 233 | * Adds coverage and code health badges to README 234 | 235 | 236 | 0.4.0 (2014-06-20) 237 | ++++++++++++++++++ 238 | 239 | * Request validation now works with path arguments. 240 | * True acceptance testing implemented for all known features. Much improved 241 | coverage. 242 | 243 | 0.4.0 (2014-06-20) 244 | ++++++++++++++++++ 245 | 246 | * True acceptance testing implemented for all known features. Much improved 247 | coverage. 248 | 249 | 0.3.2 (2014-06-16) 250 | ++++++++++++++++++ 251 | 252 | * HEAD is now an allowed HTTP method 253 | 254 | 0.3.1 (2014-06-16) 255 | ++++++++++++++++++ 256 | 257 | * Swagger spec is now validated on startup 258 | * Fixes bug where multiple methods with the same URL were not resolved properly 259 | * Fixes bug with validating non-string args in paths and query args 260 | * Fixes bug with referencing models from POST bodies 261 | 262 | 0.3.0 (2014-05-29) 263 | ++++++++++++++++++ 264 | 265 | * Response validation can be disabled via configuration 266 | * Supports Python 3.3 and 3.4! 267 | 268 | 0.2.2 (2014-05-28) 269 | ++++++++++++++++++ 270 | 271 | * Adds readthedocs links, travis badge to README 272 | * Requests missing bodies return 400 instead of causing tracebacks 273 | 274 | 0.2.1 (2014-05-15) 275 | ++++++++++++++++++ 276 | 277 | * Requests to non-existant endpoints now return 400 errors 278 | 279 | 0.1.1 (2014-05-13) 280 | ++++++++++++++++++ 281 | 282 | * Build docs now live at ``docs/build/html`` 283 | 284 | 0.1.0 (2014-05-12) 285 | ++++++++++++++++++ 286 | 287 | * Initial version. Supports very basic validation of incoming requests. 288 | -------------------------------------------------------------------------------- /tests/acceptance/response_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | 4 | from contextlib import contextmanager 5 | 6 | import mock 7 | import pyramid.testing 8 | import pytest 9 | import simplejson 10 | from pyramid.config import Configurator 11 | from pyramid.interfaces import IRoutesMapper 12 | from pyramid.registry import Registry 13 | from pyramid.response import Response 14 | from pyramid.urldispatch import RoutesMapper 15 | from webob.multidict import MultiDict 16 | from webtest import AppError 17 | 18 | import pyramid_swagger.tween 19 | from pyramid_swagger.exceptions import ResponseValidationError 20 | from pyramid_swagger.ingest import compile_swagger_schema 21 | from pyramid_swagger.ingest import get_resource_listing 22 | from pyramid_swagger.tween import validation_tween_factory 23 | from tests.acceptance.request_test import build_test_app 24 | 25 | 26 | class CustomResponseValidationException(Exception): 27 | pass 28 | 29 | 30 | class EnhancedDummyRequest(pyramid.testing.DummyRequest): 31 | """ 32 | pyramid.testing.DummyRequest doesn't support MultiDicts like the real 33 | pyramid.request.Request so this is the next best thing. 34 | """ 35 | 36 | def __init__(self, **kw): 37 | super(EnhancedDummyRequest, self).__init__(**kw) 38 | self.GET = MultiDict(self.GET) 39 | # Make sure content_type attr exists is not passed in via **kw 40 | self.content_type = getattr(self, 'content_type', None) 41 | 42 | 43 | @contextmanager 44 | def validation_context(request, response=None): 45 | try: 46 | yield 47 | except Exception: 48 | raise CustomResponseValidationException 49 | 50 | 51 | validation_ctx_path = 'tests.acceptance.response_test.validation_context' 52 | 53 | 54 | def get_registry(settings): 55 | registry = Registry('testing') 56 | config = Configurator(registry=registry) 57 | if getattr(registry, 'settings', None) is None: 58 | config._set_settings(settings) 59 | registry.registerUtility(RoutesMapper(), IRoutesMapper) 60 | config.commit() 61 | return registry 62 | 63 | 64 | def get_swagger_schema(schema_dir='tests/sample_schemas/good_app/'): 65 | return compile_swagger_schema( 66 | schema_dir, 67 | get_resource_listing(schema_dir, False) 68 | ) 69 | 70 | 71 | def _validate_against_tween(request, response=None, **overrides): 72 | """ 73 | Acceptance testing helper for testing the validation tween with Swagger 1.2 74 | responses. 75 | 76 | :param request: pytest fixture 77 | :param response: standard fixture by default 78 | """ 79 | def handler(request): 80 | return response or Response() 81 | 82 | settings = dict({ 83 | 'pyramid_swagger.swagger_versions': ['1.2'], 84 | 'pyramid_swagger.enable_swagger_spec_validation': False, 85 | 'pyramid_swagger.schema_directory': 'tests/sample_schemas/good_app/'}, 86 | **overrides 87 | ) 88 | settings['pyramid_swagger.schema12'] = get_swagger_schema() 89 | settings['pyramid_swagger.schema20'] = None 90 | registry = get_registry(settings) 91 | 92 | # Let's make request validation a no-op so we can focus our tests. 93 | with mock.patch.object(pyramid_swagger.tween, 'validate_request'): 94 | validation_tween_factory(handler, registry)(request) 95 | 96 | 97 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 98 | def test_response_validation_enabled_by_default(): 99 | request = EnhancedDummyRequest( 100 | method='GET', 101 | path='/sample/path_arg1/resource', 102 | params={'required_arg': 'test'}, 103 | matchdict={'path_arg': 'path_arg1'}, 104 | ) 105 | # Omit the logging_info key from the response. If response validation 106 | # occurs, we'll fail it. 107 | response = Response( 108 | body=simplejson.dumps({'raw_response': 'foo'}), 109 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 110 | ) 111 | with pytest.raises(ResponseValidationError) as excinfo: 112 | _validate_against_tween(request, response=response) 113 | assert "'logging_info' is a required property" in str(excinfo.value) 114 | 115 | 116 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 117 | def test_500_when_response_is_missing_required_field(): 118 | request = EnhancedDummyRequest( 119 | method='GET', 120 | path='/sample/path_arg1/resource', 121 | params={'required_arg': 'test'}, 122 | matchdict={'path_arg': 'path_arg1'}, 123 | ) 124 | # Omit the logging_info key from the response. 125 | response = Response( 126 | body=simplejson.dumps({'raw_response': 'foo'}), 127 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 128 | ) 129 | with pytest.raises(ResponseValidationError) as excinfo: 130 | _validate_against_tween(request, response=response) 131 | assert "'logging_info' is a required property" in str(excinfo.value) 132 | 133 | 134 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 135 | def test_200_when_response_is_void_with_none_response(): 136 | request = EnhancedDummyRequest( 137 | method='GET', 138 | path='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}', 139 | params={'required_arg': 'test'}, 140 | matchdict={'int_arg': '1', 'float_arg': '2.0', 'boolean_arg': 'true'}, 141 | ) 142 | response = Response( 143 | body=simplejson.dumps(None), 144 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 145 | ) 146 | _validate_against_tween(request, response=response) 147 | 148 | 149 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 150 | def test_200_when_response_is_void_with_empty_response(): 151 | request = EnhancedDummyRequest( 152 | method='GET', 153 | path='/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}', 154 | params={'required_arg': 'test'}, 155 | matchdict={'int_arg': '1', 'float_arg': '2.0', 'boolean_arg': 'true'}, 156 | ) 157 | response = Response(body='{}') 158 | _validate_against_tween(request, response=response) 159 | 160 | 161 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 162 | def test_500_when_response_arg_is_wrong_type(): 163 | request = EnhancedDummyRequest( 164 | method='GET', 165 | path='/sample/path_arg1/resource', 166 | params={'required_arg': 'test'}, 167 | matchdict={'path_arg': 'path_arg1'}, 168 | ) 169 | response = Response( 170 | body=simplejson.dumps({ 171 | 'raw_response': 1.0, 172 | 'logging_info': {'foo': 'bar'} 173 | }), 174 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 175 | ) 176 | with pytest.raises(ResponseValidationError) as excinfo: 177 | _validate_against_tween(request, response=response) 178 | assert "1.0 is not of type " in str(excinfo.value) 179 | 180 | 181 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 182 | def test_500_for_bad_validated_array_response(): 183 | request = EnhancedDummyRequest( 184 | method='GET', 185 | path='/sample_array_response', 186 | ) 187 | response = Response( 188 | body=simplejson.dumps([{"enum_value": "bad_enum_value"}]), 189 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 190 | ) 191 | with pytest.raises(ResponseValidationError) as excinfo: 192 | _validate_against_tween(request, response=response) 193 | assert "is not one of [" in str(excinfo.value) 194 | 195 | 196 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 197 | def test_200_for_good_validated_array_response(): 198 | request = EnhancedDummyRequest( 199 | method='GET', 200 | path='/sample_array_response', 201 | ) 202 | response = Response( 203 | body=simplejson.dumps([{"enum_value": "good_enum_value"}]), 204 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 205 | ) 206 | 207 | _validate_against_tween(request, response=response) 208 | 209 | 210 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 211 | def test_200_for_normal_response_validation(): 212 | app = build_test_app( 213 | swagger_versions=['1.2'], 214 | **{'pyramid_swagger.enable_response_validation': True} 215 | ) 216 | response = app.post_json('/sample', {'foo': 'test', 'bar': 'test'}) 217 | assert response.status_code == 200 218 | 219 | 220 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 221 | def test_200_skip_validation_for_excluded_path(): 222 | # FIXME(#64): This test is broken and doesn't check anything. 223 | app = build_test_app( 224 | swagger_versions=['1.2'], 225 | **{'pyramid_swagger.exclude_paths': [r'^/sample/?']} 226 | ) 227 | response = app.get( 228 | '/sample/path_arg1/resource', 229 | params={'required_arg': 'test'} 230 | ) 231 | assert response.status_code == 200 232 | 233 | 234 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 235 | def test_app_error_if_path_not_in_spec_and_path_validation_disabled(): 236 | """If path missing and validation is disabled we want to let something else 237 | handle the error. TestApp throws an AppError, but Pyramid would throw a 238 | HTTPNotFound exception. 239 | """ 240 | with pytest.raises(AppError): 241 | app = build_test_app( 242 | swagger_versions=['1.2'], 243 | **{'pyramid_swagger.enable_path_validation': False} 244 | ) 245 | assert app.get('/this/path/doesnt/exist') 246 | 247 | 248 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 249 | def test_error_handling_for_12(): 250 | app = build_test_app( 251 | swagger_versions=['1.2'], 252 | **{'pyramid_swagger.enable_response_validation': True} 253 | ) 254 | # Should throw 400 and not 500 (500 is thrown by pyramid_swagger when 255 | # response_validation is True and response format does not match the 256 | # type specified by the operation's swagger spec. But that match should 257 | # be done only when the response status is 200...203) 258 | assert app.get('/throw_400', expect_errors=True).status_code == 400 259 | 260 | 261 | @pytest.mark.skip(reason="Deprecated swagger 1.2 tests are broken. Skip instead of fixing.") 262 | def test_response_validation_context(): 263 | request = EnhancedDummyRequest( 264 | method='GET', 265 | path='/sample/path_arg1/resource', 266 | params={'required_arg': 'test'}, 267 | matchdict={'path_arg': 'path_arg1'}, 268 | ) 269 | # Omit the logging_info key from the response. 270 | response = Response( 271 | body=simplejson.dumps({'raw_response': 'foo'}), 272 | headers={'Content-Type': 'application/json; charset=UTF-8'}, 273 | ) 274 | with pytest.raises(CustomResponseValidationException): 275 | _validate_against_tween( 276 | request, 277 | response=response, 278 | **{'pyramid_swagger.validation_context_path': validation_ctx_path} 279 | ) 280 | -------------------------------------------------------------------------------- /pyramid_swagger/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Module for automatically serving /api-docs* via Pyramid. 4 | """ 5 | from __future__ import absolute_import 6 | 7 | import copy 8 | import os.path 9 | 10 | import simplejson 11 | import yaml 12 | from bravado_core.spec import strip_xscope 13 | from six.moves.urllib.parse import urlparse 14 | from six.moves.urllib.parse import urlunparse 15 | from six.moves.urllib.request import pathname2url 16 | 17 | from pyramid_swagger.model import PyramidEndpoint 18 | 19 | 20 | # TODO: document that this is now a public interface 21 | def register_api_doc_endpoints(config, endpoints, base_path='/api-docs'): 22 | """Create and register pyramid endpoints to service swagger api docs. 23 | Routes and views will be registered on the `config` at `path`. 24 | 25 | :param config: a pyramid configuration to register the new views and routes 26 | :type config: :class:`pyramid.config.Configurator` 27 | :param endpoints: a list of endpoints to register as routes and views 28 | :type endpoints: a list of :class:`pyramid_swagger.model.PyramidEndpoint` 29 | :param base_path: the base path used to register api doc endpoints. 30 | Defaults to `/api-docs`. 31 | :type base_path: string 32 | """ 33 | for endpoint in endpoints: 34 | path = base_path.rstrip('/') + endpoint.path 35 | config.add_route(endpoint.route_name, path) 36 | config.add_view( 37 | endpoint.view, 38 | route_name=endpoint.route_name, 39 | renderer=endpoint.renderer) 40 | 41 | 42 | def build_swagger_12_endpoints(resource_listing, api_declarations): 43 | """ 44 | :param resource_listing: JSON representing a Swagger 1.2 resource listing 45 | :type resource_listing: dict 46 | :param api_declarations: JSON representing Swagger 1.2 api declarations 47 | :type api_declarations: dict 48 | :rtype: iterable of :class:`pyramid_swagger.model.PyramidEndpoint` 49 | """ 50 | yield build_swagger_12_resource_listing(resource_listing) 51 | 52 | for name, filepath in api_declarations.items(): 53 | with open(filepath) as input_file: 54 | yield build_swagger_12_api_declaration( 55 | name, simplejson.load(input_file)) 56 | 57 | 58 | def build_swagger_12_resource_listing(resource_listing): 59 | """ 60 | :param resource_listing: JSON representing a Swagger 1.2 resource listing 61 | :type resource_listing: dict 62 | :rtype: :class:`pyramid_swagger.model.PyramidEndpoint` 63 | """ 64 | def view_for_resource_listing(request): 65 | # Thanks to the magic of closures, this means we gracefully return JSON 66 | # without file IO at request time. 67 | return resource_listing 68 | 69 | return PyramidEndpoint( 70 | path='', 71 | route_name='pyramid_swagger.swagger12.api_docs', 72 | view=view_for_resource_listing, 73 | renderer='json') 74 | 75 | 76 | def build_swagger_12_api_declaration(resource_name, api_declaration): 77 | """ 78 | :param resource_name: The `path` parameter from the resource listing for 79 | this resource. 80 | :type resource_name: string 81 | :param api_declaration: JSON representing a Swagger 1.2 api declaration 82 | :type api_declaration: dict 83 | :rtype: :class:`pyramid_swagger.model.PyramidEndpoint` 84 | """ 85 | # NOTE: This means our resource paths are currently constrained to be valid 86 | # pyramid routes! (minus the leading /) 87 | route_name = 'pyramid_swagger.swagger12.apidocs-{0}'.format(resource_name) 88 | return PyramidEndpoint( 89 | path='/{0}'.format(resource_name), 90 | route_name=route_name, 91 | view=build_swagger_12_api_declaration_view(api_declaration), 92 | renderer='json') 93 | 94 | 95 | def build_swagger_12_api_declaration_view(api_declaration_json): 96 | """Thanks to the magic of closures, this means we gracefully return JSON 97 | without file IO at request time. 98 | """ 99 | def view_for_api_declaration(request): 100 | # Note that we rewrite basePath to always point at this server's root. 101 | return dict( 102 | api_declaration_json, 103 | basePath=str(request.application_url), 104 | ) 105 | return view_for_api_declaration 106 | 107 | 108 | class NodeWalker(object): 109 | def __init__(self): 110 | pass 111 | 112 | def walk(self, item, *args, **kwargs): 113 | dupe = copy.deepcopy(item) 114 | return self._walk(dupe, *args, **kwargs) 115 | 116 | def _walk(self, item, *args, **kwargs): 117 | if isinstance(item, list): 118 | return self._walk_list(item, *args, **kwargs) 119 | 120 | elif isinstance(item, dict): 121 | return self._walk_dict(item, *args, **kwargs) 122 | 123 | else: 124 | return self._walk_item(item, *args, **kwargs) 125 | 126 | def _walk_list(self, item, *args, **kwargs): 127 | for index, subitem in enumerate(item): 128 | item[index] = self._walk(subitem, *args, **kwargs) 129 | return item 130 | 131 | def _walk_dict(self, item, *args, **kwargs): 132 | for key, value in item.items(): 133 | item[key] = self._walk_dict_item(key, value, *args, **kwargs) 134 | return item 135 | 136 | def _walk_dict_item(self, key, value, *args, **kwargs): 137 | return self._walk(value, *args, **kwargs) 138 | 139 | def _walk_item(self, value, *args, **kwargs): 140 | return value 141 | 142 | 143 | def get_path_if_relative(url): 144 | parts = urlparse(url) 145 | 146 | if parts.scheme or parts.netloc: 147 | # only rewrite relative paths 148 | return 149 | 150 | if not parts.path: 151 | # don't rewrite internal refs 152 | return 153 | 154 | if parts.path.startswith('/'): 155 | # don't rewrite absolute refs 156 | return 157 | 158 | return parts 159 | 160 | 161 | class NodeWalkerForRefFiles(NodeWalker): 162 | def walk(self, spec): 163 | all_refs = [] 164 | 165 | spec_fname = spec.origin_url 166 | if spec_fname.startswith('file://'): 167 | spec_fname = spec_fname.replace('file://', '') 168 | spec_dirname = os.path.dirname(spec_fname) 169 | 170 | parent = super(NodeWalkerForRefFiles, self) 171 | parent.walk(spec.client_spec_dict, spec, spec_dirname, all_refs) 172 | 173 | all_refs = [os.path.relpath(f, spec_dirname) for f in all_refs] 174 | all_refs = set(all_refs) 175 | 176 | core_dirname, core_fname = os.path.split(spec_fname) 177 | all_refs.add(core_fname) 178 | 179 | return all_refs 180 | 181 | def _walk_dict_item(self, key, value, spec, dirname, all_refs): 182 | if key != '$ref': 183 | parent = super(NodeWalkerForRefFiles, self) 184 | return parent._walk_dict_item(key, value, spec, dirname, all_refs) 185 | 186 | # assume $ref is the only key in the dict 187 | parts = get_path_if_relative(value) 188 | if not parts: 189 | return value 190 | 191 | full_fname = os.path.join(dirname, parts.path) 192 | norm_fname = os.path.normpath(full_fname) 193 | all_refs.append(norm_fname) 194 | 195 | with spec.resolver.resolving(value) as spec_dict: 196 | dupe = copy.deepcopy(spec_dict) 197 | self._walk(dupe, spec, os.path.dirname(norm_fname), all_refs) 198 | 199 | 200 | class NodeWalkerForCleaningRefs(NodeWalker): 201 | def walk(self, item, schema_format): 202 | parent = super(NodeWalkerForCleaningRefs, self) 203 | return parent.walk(item, schema_format) 204 | 205 | @staticmethod 206 | def fix_ref(ref, schema_format): 207 | parts = get_path_if_relative(ref) 208 | if not parts: 209 | return 210 | 211 | path, ext = os.path.splitext(parts.path) 212 | return urlunparse([ 213 | parts.scheme, 214 | parts.netloc, 215 | '{0}.{1}'.format(path, schema_format), 216 | parts.params, 217 | parts.query, 218 | parts.fragment, 219 | ]) 220 | 221 | def _walk_dict_item(self, key, value, schema_format): 222 | if key != '$ref': 223 | parent = super(NodeWalkerForCleaningRefs, self) 224 | return parent._walk_dict_item(key, value, schema_format) 225 | 226 | return self.fix_ref(value, schema_format) or value 227 | 228 | 229 | class YamlRendererFactory(object): 230 | def __init__(self, info): 231 | pass 232 | 233 | def __call__(self, value, system): 234 | response = system['request'].response 235 | response.headers['Content-Type'] = 'application/x-yaml; charset=UTF-8' 236 | return yaml.safe_dump(value).encode('utf-8') 237 | 238 | 239 | def build_swagger_20_swagger_schema_views(config): 240 | settings = config.registry.settings 241 | if settings.get('pyramid_swagger.dereference_served_schema'): 242 | views = _build_dereferenced_swagger_20_schema_views(config) 243 | else: 244 | views = _build_swagger_20_schema_views(config) 245 | return views 246 | 247 | 248 | def _build_dereferenced_swagger_20_schema_views(config): 249 | def view_for_swagger_schema(request): 250 | settings = config.registry.settings 251 | resolved_dict = settings.get('pyramid_swagger.schema20_resolved') 252 | if not resolved_dict: 253 | resolved_dict = settings['pyramid_swagger.schema20'].flattened_spec 254 | settings['pyramid_swagger.schema20_resolved'] = resolved_dict 255 | return resolved_dict 256 | 257 | for schema_format in ['yaml', 'json']: 258 | route_name = 'pyramid_swagger.swagger20.api_docs.{0}'\ 259 | .format(schema_format) 260 | yield PyramidEndpoint( 261 | path='/swagger.{0}'.format(schema_format), 262 | view=view_for_swagger_schema, 263 | route_name=route_name, 264 | renderer=schema_format, 265 | ) 266 | 267 | 268 | def _build_swagger_20_schema_views(config): 269 | spec = config.registry.settings['pyramid_swagger.schema20'] 270 | 271 | walker = NodeWalkerForRefFiles() 272 | all_files = walker.walk(spec) 273 | 274 | file_map = {} 275 | 276 | def view_for_swagger_schema(request): 277 | _, ext = os.path.splitext(request.path) 278 | ext = ext.lstrip('.') 279 | 280 | base_path = config.registry.settings\ 281 | .get('pyramid_swagger.base_path_api_docs', '').rstrip('/') 282 | 283 | key_path = request.path_info[len(base_path):] 284 | 285 | actual_fname = file_map[key_path] 286 | 287 | with spec.resolver.resolving(actual_fname) as spec_dict: 288 | clean_response = strip_xscope(spec_dict) 289 | ref_walker = NodeWalkerForCleaningRefs() 290 | fixed_spec = ref_walker.walk(clean_response, ext) 291 | return fixed_spec 292 | 293 | for ref_fname in all_files: 294 | ref_fname_parts = os.path.splitext(pathname2url(ref_fname)) 295 | for schema_format in ['yaml', 'json']: 296 | route_name = 'pyramid_swagger.swagger20.api_docs.{0}.{1}'\ 297 | .format(ref_fname.replace('/', '.'), schema_format) 298 | path = '/{0}.{1}'.format(ref_fname_parts[0], schema_format) 299 | file_map[path] = ref_fname 300 | yield PyramidEndpoint( 301 | path=path, 302 | route_name=route_name, 303 | view=view_for_swagger_schema, 304 | renderer=schema_format, 305 | ) 306 | -------------------------------------------------------------------------------- /tests/sample_schemas/good_app/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Title was not specified", 5 | "version": "0.1" 6 | }, 7 | "produces": ["application/json"], 8 | "paths": { 9 | "/no_models": { 10 | "get": { 11 | "responses": { 12 | "200": { 13 | "description": "No response was specified" 14 | } 15 | }, 16 | "description": "", 17 | "operationId": "no_models_get" 18 | } 19 | }, 20 | "/echo_date": { 21 | "post": { 22 | "parameters": [ 23 | { 24 | "in": "body", 25 | "name": "body", 26 | "required": true, 27 | "schema": { 28 | "$ref": "#/definitions/object_with_formats" 29 | } 30 | } 31 | ], 32 | "responses": { 33 | "200": { 34 | "description": "HTTP/200 OK", 35 | "schema": { 36 | "$ref": "#/definitions/object_with_formats" 37 | } 38 | } 39 | }, 40 | "operationId": "echo_date" 41 | } 42 | }, 43 | "/post_endpoint_with_optional_body": { 44 | "post": { 45 | "description": "Returns the request body length. Used to verify that optional body parameters are well handled", 46 | "parameters": [ 47 | { 48 | "in": "body", 49 | "name": "body", 50 | "required": false, 51 | "schema": { 52 | "type": "object" 53 | } 54 | } 55 | ], 56 | "responses": { 57 | "200": { 58 | "description": "HTTP/200 OK. Return request body length", 59 | "schema": { 60 | "type": "integer" 61 | } 62 | } 63 | }, 64 | "operationId": "post_endpoint_with_optional_body" 65 | } 66 | }, 67 | "/echo_date_json_renderer": { 68 | "post": { 69 | "description": "This endpoint is used in tests/acceptance/request_test.py and requires to be identical to /echo_date endpoint", 70 | "parameters": [ 71 | { 72 | "in": "body", 73 | "name": "body", 74 | "required": true, 75 | "schema": { 76 | "$ref": "#/definitions/object_with_formats" 77 | } 78 | } 79 | ], 80 | "responses": { 81 | "200": { 82 | "description": "HTTP/200 OK", 83 | "schema": { 84 | "$ref": "#/definitions/object_with_formats" 85 | } 86 | } 87 | }, 88 | "operationId": "echo_date_json_renderer" 89 | } 90 | }, 91 | "/sample/{path_arg}/resource": { 92 | "get": { 93 | "responses": { 94 | "200": { 95 | "description": "Return a standard_response", 96 | "schema": { 97 | "$ref": "#/definitions/standard_response" 98 | } 99 | } 100 | }, 101 | "description": "", 102 | "operationId": "standard", 103 | "parameters": [ 104 | { 105 | "in": "path", 106 | "name": "path_arg", 107 | "required": true, 108 | "type": "string", 109 | "enum": ["path_arg1", "path_arg2"] 110 | }, 111 | { 112 | "in": "query", 113 | "name": "required_arg", 114 | "required": true, 115 | "type": "string" 116 | }, 117 | { 118 | "in": "query", 119 | "name": "optional_arg", 120 | "required": false, 121 | "type": "string" 122 | } 123 | ] 124 | } 125 | }, 126 | "/sample/nonstring/{int_arg}/{float_arg}/{boolean_arg}": { 127 | "get": { 128 | "responses": { 129 | "200": { 130 | "description": "No response was specified" 131 | } 132 | }, 133 | "description": "", 134 | "operationId": "sample_nonstring", 135 | "parameters": [ 136 | { 137 | "in": "path", 138 | "name": "int_arg", 139 | "required": true, 140 | "type": "integer" 141 | }, 142 | { 143 | "in": "path", 144 | "name": "float_arg", 145 | "required": true, 146 | "type": "number" 147 | }, 148 | { 149 | "in": "path", 150 | "name": "boolean_arg", 151 | "required": true, 152 | "type": "boolean" 153 | } 154 | ] 155 | } 156 | }, 157 | "/sample/header": { 158 | "get": { 159 | "responses": { 160 | "200": { 161 | "description": "No response was specified" 162 | } 163 | }, 164 | "description": "", 165 | "operationId": "sample_header", 166 | "parameters": [ 167 | { 168 | "in": "header", 169 | "name": "X-Force", 170 | "required": true, 171 | "type": "boolean" 172 | } 173 | ] 174 | } 175 | }, 176 | "/sample/authentication": { 177 | "get": { 178 | "responses": { 179 | "200": { 180 | "description": "No response was specified" 181 | } 182 | }, 183 | "description": "", 184 | "operationId": "sample_authentication", 185 | "security": [ 186 | {"AuthToken": []} 187 | ] 188 | } 189 | }, 190 | "/sample/no_response_schema": { 191 | "get": { 192 | "responses": { 193 | "200": { 194 | "description": "Body presence status" 195 | } 196 | }, 197 | "description": "", 198 | "operationId": "sample_no_response_schema" 199 | } 200 | }, 201 | "/sample": { 202 | "get": { 203 | "responses": { 204 | "200": { 205 | "description": "No response was specified" 206 | } 207 | }, 208 | "description": "", 209 | "operationId": "sample_get" 210 | }, 211 | "post": { 212 | "responses": { 213 | "200": { 214 | "description": "No response was specified" 215 | } 216 | }, 217 | "description": "", 218 | "operationId": "sample_post", 219 | "parameters": [ 220 | { 221 | "in": "query", 222 | "name": "optional_string", 223 | "required": false, 224 | "type": "string" 225 | }, 226 | { 227 | "in": "body", 228 | "name": "content", 229 | "required": true, 230 | "schema": { 231 | "$ref": "#/definitions/body_model" 232 | } 233 | } 234 | ] 235 | } 236 | }, 237 | "/sample_array_response": { 238 | "get": { 239 | "responses": { 240 | "200": { 241 | "description": "List of array_content_model", 242 | "schema": { 243 | "type": "array", 244 | "items": { 245 | "$ref": "#/definitions/array_content_model" 246 | } 247 | } 248 | } 249 | }, 250 | "description": "", 251 | "operationId": "sample_get_array_response" 252 | } 253 | }, 254 | "/post_with_primitive_body": { 255 | "post": { 256 | "responses": { 257 | "200": { 258 | "description": "No response was specified" 259 | } 260 | }, 261 | "description": "", 262 | "operationId": "post_with_primitive_body", 263 | "parameters": [ 264 | { 265 | "in": "body", 266 | "name": "content", 267 | "required": true, 268 | "schema": { 269 | "type": "array", 270 | "items": { 271 | "type": "string" 272 | } 273 | } 274 | } 275 | ] 276 | } 277 | }, 278 | "/post_with_form_params": { 279 | "post": { 280 | "responses": { 281 | "200": { 282 | "description": "No response was specified" 283 | } 284 | }, 285 | "description": "", 286 | "operationId": "post_with_form_params", 287 | "parameters": [ 288 | { 289 | "in": "formData", 290 | "name": "form_param", 291 | "required": true, 292 | "type": "integer" 293 | } 294 | ] 295 | } 296 | }, 297 | "/post_with_file_upload": { 298 | "post": { 299 | "responses": { 300 | "200": { 301 | "description": "Upload succeeded" 302 | } 303 | }, 304 | "summary": "Test uploading a file", 305 | "operationId": "post_with_file_upload", 306 | "parameters": [ 307 | { 308 | "in": "formData", 309 | "name": "photo_file", 310 | "required": true, 311 | "type": "file" 312 | } 313 | ] 314 | } 315 | }, 316 | "/get_with_non_string_query_args": { 317 | "get": { 318 | "responses": { 319 | "200": { 320 | "description": "No response was specified" 321 | } 322 | }, 323 | "description": "", 324 | "operationId": "get_with_non_string_query_args", 325 | "parameters": [ 326 | { 327 | "in": "query", 328 | "name": "int_arg", 329 | "required": true, 330 | "type": "integer" 331 | }, 332 | { 333 | "in": "query", 334 | "name": "float_arg", 335 | "required": true, 336 | "type": "number" 337 | }, 338 | { 339 | "in": "query", 340 | "name": "boolean_arg", 341 | "required": true, 342 | "type": "boolean" 343 | } 344 | ] 345 | } 346 | } 347 | }, 348 | "host": "localhost:9999", 349 | "schemes": [ 350 | "http" 351 | ], 352 | "definitions": { 353 | "object_with_formats": { 354 | "properties": { 355 | "date": { 356 | "type": "string", 357 | "format": "date" 358 | } 359 | }, 360 | "required": [ 361 | "date" 362 | ], 363 | "type": "object" 364 | }, 365 | "standard_response": { 366 | "type": "object", 367 | "required": [ 368 | "raw_response", 369 | "logging_info" 370 | ], 371 | "additionalProperties": false, 372 | "properties": { 373 | "raw_response": { 374 | "type": "string" 375 | }, 376 | "logging_info": { 377 | "type": "object" 378 | } 379 | } 380 | }, 381 | "body_model": { 382 | "type": "object", 383 | "required": [ 384 | "foo" 385 | ], 386 | "additionalProperties": false, 387 | "properties": { 388 | "foo": { 389 | "type": "string" 390 | }, 391 | "bar": { 392 | "type": "string" 393 | } 394 | } 395 | }, 396 | "array_content_model": { 397 | "required": [ 398 | "enum_value" 399 | ], 400 | "properties": { 401 | "enum_value": { 402 | "type": "string", 403 | "enum": [ 404 | "good_enum_value" 405 | ] 406 | } 407 | } 408 | } 409 | }, 410 | "securityDefinitions": { 411 | "AuthToken": { 412 | "description": "Dummy Auth Token", 413 | "in": "header", 414 | "name": "X-Auth-Token", 415 | "type": "apiKey" 416 | } 417 | } 418 | } 419 | --------------------------------------------------------------------------------