├── tests ├── __init__.py ├── test_with_static_file_mount.py ├── test_with_lifespan.py ├── test_oauth.py ├── test_with_tags.py ├── test_with_root_path.py ├── test_docs_customization.py ├── test_websocket.py └── test_simple.py ├── examples ├── __init__.py ├── with_static_file_mount.py ├── with_root_path.py ├── oauth.py ├── websocket.py ├── with_lifespan.py ├── tags.py ├── simple.py └── docs_customization.py ├── Manifest.in ├── fastapi_versionizer ├── py.typed ├── __init__.py └── versionizer.py ├── mypy.ini ├── requirements.txt ├── .flake8 ├── commitlint.parser-preset.js ├── .github ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── release.yml │ ├── deploy.yml │ └── pull-request.yml ├── requirements.dev.txt ├── package.json ├── commitlint.config.js ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── setup.py ├── Makefile ├── CONTRIBUTE.md ├── .releaserc ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Manifest.in: -------------------------------------------------------------------------------- 1 | prune tests 2 | -------------------------------------------------------------------------------- /fastapi_versionizer/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.86.0 2 | natsort==8.* 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | multiline-quotes = ' 4 | docstring-quotes = " 5 | ignore = T201, W504 6 | -------------------------------------------------------------------------------- /fastapi_versionizer/__init__.py: -------------------------------------------------------------------------------- 1 | from .versionizer import Versionizer, api_version 2 | 3 | __all__ = [ 4 | 'Versionizer', 5 | 'api_version' 6 | ] 7 | -------------------------------------------------------------------------------- /commitlint.parser-preset.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOpts: { 3 | headerPattern: /^(.*): (.*)$/, 4 | headerCorrespondence: ['type', 'subject'] 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Subject of the issue 2 | Describe your issue here. 3 | 4 | ### Your environment 5 | * python version 6 | * operating system 7 | 8 | ### Steps to reproduce 9 | Tell us how to reproduce this issue 10 | 11 | ### Expected behaviour 12 | Tell us what should happen 13 | 14 | ### Actual behaviour 15 | Tell us what happens instead 16 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | flake8==5.0.4 4 | flake8-builtins==2.1.0 5 | flake8-comprehensions==3.13.0 6 | flake8-eradicate==1.4.0 7 | flake8-length==0.3.1 8 | flake8-quotes==3.3.2 9 | flake8-use-fstring==1.4 10 | pytest==8.3.4 11 | mypy==1.4.1 12 | pre-commit==2.21.0 13 | pytest-cov==5.0.0 14 | safety==2.3.5 15 | twine==3.8.0 16 | types-setuptools==65.6.0.2 17 | httpx==0.28.1 18 | uvicorn==0.16.0 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Pull Request Checklist 2 | 3 | - [ ] Make sure to read the contributor guide [here](https://github.com/alexschimpf/fastapi-versionizer/tree/main/CONTRIBUTE.md). 4 | - [ ] Make sure you are making a pull request against the main branch. 5 | - [ ] Make sure your pull request title matches the requested format. 6 | - [ ] Make sure to lint, type-check, and run unit and API tests. 7 | 8 | ## Description 9 | Please describe your pull request. 10 | -------------------------------------------------------------------------------- /examples/with_static_file_mount.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.staticfiles import StaticFiles 3 | 4 | from fastapi_versionizer import Versionizer 5 | 6 | app = FastAPI() 7 | 8 | # This will not work! 9 | app.mount('/examples-not-working', StaticFiles(directory='examples'), name='examples') 10 | 11 | versions = Versionizer( 12 | app=app, 13 | ).versionize() 14 | 15 | # Only static file mounts added *after* instantiating Versionizer will work 16 | app.mount('/examples', StaticFiles(directory='examples'), name='examples') 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastapi-versionizer", 3 | "author": "Alex Schimpf", 4 | "description": "FastAPI Versionizer", 5 | "keywords": [], 6 | "version": "4.0.3", 7 | "devDependencies": { 8 | "@commitlint/cli": "^19.8.1", 9 | "@commitlint/config-conventional": "^19.8.1", 10 | "@semantic-release/changelog": "^6.0.3", 11 | "@semantic-release/git": "^10.0.1", 12 | "semantic-release": "^24.2.5" 13 | }, 14 | "scripts": { 15 | "release": "pnpm exec semantic-release" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | persist-credentials: false 13 | - name: Set up pnpm 14 | uses: pnpm/action-setup@v4.1.0 15 | with: 16 | version: 10.11.0 17 | - name: Install dependencies 18 | run: pnpm install 19 | - name: Generate a release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.CLI_PAT }} 22 | run: pnpm run release 23 | -------------------------------------------------------------------------------- /examples/with_root_path.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_versionizer.versionizer import Versionizer, api_version 4 | 5 | 6 | app = FastAPI( 7 | title='test', 8 | docs_url='/swagger', 9 | root_path='/api', 10 | root_path_in_servers=True, 11 | openapi_url='/api_schema.json', 12 | redoc_url=None 13 | ) 14 | 15 | 16 | @api_version(1) 17 | @app.get('/status', tags=['Status']) 18 | def get_status_v1() -> str: 19 | return 'Okv1' 20 | 21 | 22 | @api_version(2) 23 | @app.get('/status', tags=['Status']) 24 | def get_status_v2() -> str: 25 | return 'Okv2' 26 | 27 | 28 | versions = Versionizer( 29 | app=app, 30 | prefix_format='/v{major}', 31 | semantic_version_format='{major}', 32 | latest_prefix='/latest', 33 | include_versions_route=True, 34 | sort_routes=True 35 | ).versionize() 36 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'subject-min-length': [2, 'always', 10], 4 | 'subject-max-length': [2, 'always', 120], 5 | 'subject-case': [2, 'always', 'sentence-case'], 6 | 'subject-full-stop': [2, 'never', '.'], 7 | 'body-min-length': [2, 'always', 0], 8 | 'body-max-line-length': [2, 'always', 120], 9 | 'body-case': [2, 'always', 'sentence-case'], 10 | 'body-full-stop': [2, 'always', '.'], 11 | 'footer-min-length': [2, 'always', 0], 12 | 'footer-max-line-length': [2, 'always', 120], 13 | 'type-enum': [2, 'always', [ 14 | 'breaking', 'deps', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'style', 'test' 15 | ]], 16 | 'type-empty': [2, 'never'] 17 | }, 18 | parserPreset: './commitlint.parser-preset.js', 19 | defaultIgnores: false 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | Pipfile.Lock 28 | =* 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | coverage.xml 41 | .pytest_cache/ 42 | 43 | # pyenv 44 | .python-version 45 | 46 | # Environments 47 | .env 48 | .venv 49 | env/ 50 | venv/ 51 | 52 | # mypy 53 | .mypy_cache/ 54 | 55 | # editor config 56 | .vscode/ 57 | .idea/ 58 | 59 | # mac 60 | .DS_STORE 61 | 62 | # node 63 | node_modules/ 64 | 65 | # personal 66 | TODO 67 | -------------------------------------------------------------------------------- /tests/test_with_static_file_mount.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from unittest import TestCase 4 | from examples.with_static_file_mount import app 5 | 6 | from pathlib import Path 7 | 8 | 9 | class TestWithStaticFileMount(TestCase): 10 | def test_with_static_file_mount_example(self) -> None: 11 | test_client = TestClient(app) 12 | 13 | # Read example file from file system 14 | expected = Path('examples/with_static_file_mount.py').read_text() 15 | 16 | # Compare local file contents with the same retrieved via the static file mount 17 | self.assertEqual(expected, test_client.get('/examples/with_static_file_mount.py').text) 18 | 19 | # Check that a static mount before instantiating Versionizer will not work 20 | self.assertEqual('{"detail":"Not Found"}', 21 | test_client.get('/examples-not-working/with_static_file_mount.py').text) 22 | -------------------------------------------------------------------------------- /examples/oauth.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Annotated 2 | from fastapi import FastAPI, APIRouter, Depends 3 | from fastapi_versionizer import Versionizer 4 | from fastapi.openapi.models import OAuthFlowImplicit, OAuthFlows 5 | from fastapi.security import OAuth2 6 | 7 | 8 | oauth2_scheme = OAuth2( 9 | flows=OAuthFlows( 10 | implicit=OAuthFlowImplicit( 11 | authorizationUrl='https://login.something.com/authorize', 12 | ) 13 | ), 14 | auto_error=True 15 | ) 16 | 17 | app = FastAPI( 18 | redoc_url=None, 19 | swagger_ui_init_oauth={ 20 | 'clientId': 'my-client-id', 21 | 'scopes': 'required_scopes', 22 | } 23 | ) 24 | 25 | 26 | router = APIRouter(prefix='/test') 27 | 28 | 29 | @router.get('') 30 | def get_test(token: Annotated[str, Depends(oauth2_scheme)]) -> str: 31 | return token 32 | 33 | 34 | app.include_router(router) 35 | 36 | versions = Versionizer( 37 | app=app, 38 | prefix_format='/v{major}', 39 | semantic_version_format='{major}', 40 | latest_prefix='/latest', 41 | sort_routes=True 42 | ).versionize() 43 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/flake8 3 | rev: 5.0.4 4 | hooks: 5 | - id: flake8 6 | additional_dependencies: 7 | - flake8-builtins==2.0.1 8 | - flake8-comprehensions==3.10.1 9 | - flake8-eradicate==1.4.0 10 | - flake8-print==5.0.0 11 | - flake8-length==0.3.1 12 | - flake8-quotes==3.3.1 13 | - flake8-use-fstring==1.4 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v4.4.0 16 | hooks: 17 | - id: check-yaml 18 | - id: check-json 19 | - id: check-merge-conflict 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | - id: no-commit-to-branch 23 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 24 | rev: v9.22.0 25 | hooks: 26 | - id: commitlint 27 | stages: [commit-msg] 28 | - repo: local 29 | hooks: 30 | - id: type-check 31 | name: type-check 32 | entry: "make type-check" 33 | language: python 34 | types: [python] 35 | require_serial: true 36 | verbose: true 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alex Schimpf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | Footer 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import json 2 | from setuptools import setup 3 | 4 | 5 | with open('README.md', 'r') as readme_file: 6 | long_description = readme_file.read() 7 | 8 | with open('requirements.txt', 'r') as requirements_file: 9 | requirements_list = requirements_file.readlines() 10 | 11 | with open('package.json', 'r') as package_file: 12 | package_dict = json.loads(package_file.read().strip()) 13 | version = package_dict['version'] 14 | 15 | 16 | setup( 17 | name='fastapi_versionizer', 18 | version=version, 19 | author='Alex Schimpf', 20 | author_email='aschimpf1@gmail.com', 21 | description='API versionizer for FastAPI web applications', 22 | long_description=long_description, 23 | long_description_content_type='text/markdown', 24 | url='https://github.com/alexschimpf/fastapi-versionizer', 25 | packages=['', 'fastapi_versionizer'], 26 | package_data={ 27 | '': ['README.md', 'requirements.txt', 'package.json'], 28 | 'fastapi_versionizer': ['py.typed'] 29 | }, 30 | classifiers=[ 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | 'Programming Language :: Python :: 3.11', 35 | 'Programming Language :: Python :: 3.12', 36 | 'Programming Language :: Python :: 3.13' 37 | ], 38 | install_requires=requirements_list, 39 | python_requires='>=3.8' 40 | ) 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish-to-test-pypi: 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: test 13 | permissions: 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.8' 21 | 22 | - name: Build the distribution 23 | run: make build 24 | - name: Install the package 25 | run: make test-install-build 26 | - name: Publish package distributions to Test PyPI 27 | uses: pypa/gh-action-pypi-publish@release/v1 28 | with: 29 | repository-url: https://test.pypi.org/legacy/ 30 | 31 | publish-to-pypi: 32 | runs-on: ubuntu-latest 33 | environment: 34 | name: production 35 | # if release to test PyPI fails, still run this job 36 | # since we can check the reason for failure manually 37 | # before approving the release 38 | if: always() 39 | needs: publish-to-test-pypi 40 | permissions: 41 | id-token: write 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/setup-python@v5.3.0 46 | with: 47 | python-version: '3.8' 48 | 49 | - name: Build the distribution 50 | run: make build 51 | - name: Install the package 52 | run: make test-install-build 53 | - name: Publish package distributions to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | -------------------------------------------------------------------------------- /tests/test_with_lifespan.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from unittest import TestCase 4 | from examples.with_lifespan import app, versions 5 | 6 | 7 | class TestWithLifespanExample(TestCase): 8 | 9 | def setUp(self) -> None: 10 | self.maxDiff = None 11 | 12 | def test_with_lifespan_example(self) -> None: 13 | with TestClient(app) as test_client: 14 | 15 | self.assertListEqual([(1, 0), (2, 0)], versions) 16 | 17 | # versions route 18 | self.assertDictEqual( 19 | { 20 | 'versions': [ 21 | { 22 | 'version': '1', 23 | 'openapi_url': '/v1/api_schema.json', 24 | 'swagger_url': '/v1/swagger' 25 | }, 26 | { 27 | 'version': '2', 28 | 'openapi_url': '/v2/api_schema.json', 29 | 'swagger_url': '/v2/swagger', 30 | } 31 | ] 32 | }, 33 | test_client.get('/versions').json() 34 | ) 35 | 36 | # status route 37 | self.assertEqual(200, test_client.get('/v1/status').status_code) 38 | self.assertEqual(200, test_client.get('/v2/status').status_code) 39 | 40 | # test middleware 41 | self.assertIsNotNone( 42 | test_client.get('/v1/status').headers.get('X-Process-Time'), 43 | 'Middleware not working' 44 | ) 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GREEN := $(shell tput -Txterm setaf 2) 2 | YELLOW := $(shell tput -Txterm setaf 3) 3 | WHITE := $(shell tput -Txterm setaf 7) 4 | RESET := $(shell tput -Txterm sgr0) 5 | 6 | # help 7 | TARGET_MAX_CHAR_NUM=20 8 | help: 9 | @echo '' 10 | @echo 'Usage:' 11 | @echo ' ${YELLOW}make${RESET} ${GREEN}${RESET}' 12 | @echo '' 13 | @echo 'Targets:' 14 | @awk '/^[a-zA-Z\-\_0-9]+:/ { \ 15 | helpMessage = match(lastLine, /^# (.*)/); \ 16 | if (helpMessage) { \ 17 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 18 | helpMessage = substr(lastLine, RSTART + 2, RLENGTH); \ 19 | printf " ${YELLOW}%-$(TARGET_MAX_CHAR_NUM)s${RESET} ${GREEN}%s${RESET}\n", helpCommand, helpMessage; \ 20 | } \ 21 | } \ 22 | { lastLine = $$0 }' $(MAKEFILE_LIST) 23 | 24 | # build python package 25 | build: 26 | rm -rf dist && rm -rf fastapi_versionizer.egg-info && python setup.py sdist 27 | 28 | 29 | # test install of build 30 | test-install-build: 31 | pip install dist/$(shell ls dist | grep tar.gz | head -1) 32 | 33 | # deploy package to pypi 34 | deploy: 35 | twine upload dist/* 36 | 37 | # run all tests with coverage 38 | run-tests: 39 | pytest --cov=fastapi_versionizer --cov-fail-under=85 --no-cov-on-fail tests/ 40 | 41 | # type check python 42 | type-check: 43 | mypy . 44 | 45 | # lint 46 | lint: 47 | flake8 fastapi_versionizer tests examples 48 | 49 | # install dev dependencies 50 | install-dev: 51 | pip install -r requirements.dev.txt 52 | 53 | # install pre-commit 54 | install-pre-commit: 55 | pre-commit install --hook-type commit-msg 56 | 57 | # check dependency vulnerabilities 58 | check-vulnerabilities: 59 | safety check --full-report --file requirements.txt 60 | -------------------------------------------------------------------------------- /examples/websocket.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="no-any-return" 2 | # flake8: noqa: A003 3 | 4 | from fastapi import FastAPI, APIRouter, WebSocket 5 | 6 | from fastapi_versionizer.versionizer import Versionizer, api_version 7 | 8 | 9 | app = FastAPI( 10 | title='test', 11 | docs_url='/swagger', 12 | openapi_url='/api_schema.json', 13 | redoc_url=None, 14 | description='Websocket example of FastAPI Versionizer.', 15 | terms_of_service='https://github.com/alexschimpf/fastapi-versionizer' 16 | ) 17 | chat_router = APIRouter( 18 | prefix='/chatterbox', 19 | tags=['Chatting'] 20 | ) 21 | 22 | 23 | @api_version(1) 24 | @chat_router.get('', deprecated=True) 25 | def get_explaination() -> str: 26 | return 'v1' 27 | 28 | 29 | @api_version(2) 30 | @chat_router.get('') 31 | def get_explaination_v2() -> str: 32 | return 'v2' 33 | 34 | @api_version(1) 35 | @chat_router.websocket('') 36 | async def chatterbox(websocket: WebSocket) -> None: 37 | await websocket.accept() 38 | while True: 39 | msg = await websocket.receive_text() 40 | await websocket.send_text(msg) 41 | 42 | @api_version(2) 43 | @chat_router.websocket('') 44 | async def chatterbox_v2(websocket: WebSocket) -> None: 45 | await websocket.accept() 46 | while True: 47 | msg = await websocket.receive_text() 48 | await websocket.send_text("pong" if msg == "ping" else f"Your message: {msg}") 49 | 50 | app.include_router(chat_router) 51 | 52 | versions = Versionizer( 53 | app=app, 54 | prefix_format='/v{major}', 55 | semantic_version_format='{major}', 56 | latest_prefix='/latest', 57 | include_versions_route=True, 58 | sort_routes=True 59 | ).versionize() 60 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull request checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | lint-commits: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: wagoid/commitlint-github-action@v5 14 | 15 | lint-python: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5.3.0 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - run: make install-dev 26 | - run: make lint 27 | 28 | type-check: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-python@v5.3.0 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | - run: make install-dev 39 | - run: make type-check 40 | 41 | run-tests: 42 | runs-on: ubuntu-latest 43 | strategy: 44 | matrix: 45 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13' ] 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: actions/setup-python@v5.3.0 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | - run: make install-dev 52 | - run: make run-tests 53 | 54 | safety: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | - uses: actions/setup-python@v5.3.0 59 | with: 60 | python-version: 3.11 61 | - run: pip install safety==2.3.5 62 | - run: make check-vulnerabilities 63 | -------------------------------------------------------------------------------- /tests/test_oauth.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from unittest import TestCase 4 | from examples.oauth import app 5 | 6 | 7 | class TestOAuthExample(TestCase): 8 | 9 | def setUp(self) -> None: 10 | self.maxDiff = None 11 | 12 | def test_oauth_example(self) -> None: 13 | test_client = TestClient(app) 14 | 15 | components = test_client.get('/openapi.json').json()['components'] 16 | self.assertDictEqual({ 17 | 'securitySchemes': { 18 | 'OAuth2': { 19 | 'flows': { 20 | 'implicit': { 21 | 'authorizationUrl': 'https://login.something.com/authorize', 22 | 'scopes': {} 23 | } 24 | }, 25 | 'type': 'oauth2' 26 | } 27 | } 28 | }, components) 29 | self.assertIn( 30 | 'ui.initOAuth({"clientId": "my-client-id", "scopes": "required_scopes"})', 31 | test_client.get('/docs').text 32 | ) 33 | 34 | v1_components = test_client.get('/v1/openapi.json').json()['components'] 35 | self.assertDictEqual({ 36 | 'securitySchemes': { 37 | 'OAuth2': { 38 | 'flows': { 39 | 'implicit': { 40 | 'authorizationUrl': 'https://login.something.com/authorize', 41 | 'scopes': {} 42 | } 43 | }, 44 | 'type': 'oauth2' 45 | } 46 | } 47 | }, v1_components) 48 | self.assertIn( 49 | 'ui.initOAuth({"clientId": "my-client-id", "scopes": "required_scopes"})', 50 | test_client.get('/v1/docs').text 51 | ) 52 | -------------------------------------------------------------------------------- /examples/with_lifespan.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="no-any-return" 2 | # flake8: noqa: A003 3 | 4 | from contextlib import asynccontextmanager 5 | import time 6 | from typing import AsyncGenerator, Awaitable, Callable 7 | from fastapi import FastAPI, APIRouter, Request, Response 8 | 9 | from fastapi_versionizer.versionizer import Versionizer, api_version 10 | 11 | 12 | class TestLifeSpan: 13 | initialized = False 14 | 15 | @classmethod 16 | def init(cls) -> None: 17 | cls.initialized = True 18 | 19 | 20 | @asynccontextmanager 21 | async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: 22 | try: 23 | TestLifeSpan.init() 24 | yield 25 | finally: 26 | pass 27 | 28 | 29 | app = FastAPI( 30 | title='test', 31 | docs_url='/swagger', 32 | openapi_url='/api_schema.json', 33 | redoc_url=None, 34 | lifespan=lifespan 35 | ) 36 | status_router = APIRouter( 37 | prefix='/status', 38 | tags=['Status'] 39 | ) 40 | 41 | @app.middleware('http') 42 | async def add_process_time_header( 43 | request: Request, call_next: Callable[[Request], Awaitable[Response]] 44 | ) -> Response: 45 | start_time = time.time() 46 | response = await call_next(request) 47 | process_time = time.time() - start_time 48 | response.headers['X-Process-Time'] = str(process_time) 49 | return response 50 | 51 | @api_version(1) 52 | @status_router.get('', deprecated=True) 53 | def get_v1_status() -> str: 54 | if not TestLifeSpan.initialized: 55 | raise Exception('Lifespan not initialized') 56 | return 'Ok' 57 | 58 | 59 | @api_version(2) 60 | @status_router.get('') 61 | def get_v2_status() -> str: 62 | if not TestLifeSpan.initialized: 63 | raise Exception('Lifespan not initialized') 64 | return 'Ok' 65 | 66 | 67 | app.include_router(status_router) 68 | 69 | versions = Versionizer( 70 | app=app, 71 | prefix_format='/v{major}', 72 | semantic_version_format='{major}', 73 | latest_prefix='/latest', 74 | include_versions_route=True, 75 | sort_routes=True 76 | ).versionize() 77 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | ## Issues 2 | 1. Make sure your issue hasn't already been submitted. 3 | 2. Make sure to fill out the issue template with all necessary details. 4 | 5 | ## Pull Requests 6 | - All pull requests should be made against the `main` branch 7 | - All necessary checks are run on pull requests 8 | - Python linting 9 | - Python type-checking 10 | - Unit tests 11 | - Commit message linting 12 | - Dependency vulnerability scanning 13 | - All checks must pass before merging 14 | - All pull requests with code changes should contain tests or updates to existing tests 15 | - Exceptions are possible with a sufficient reason 16 | - Commit messages are linted via [commitlint](https://commitlint.js.org/#/) 17 | - Make sure to run steps 3 and 4 below under Installation for this to install properly 18 | - Commit messages automatically populate the changelog and tags, so they should be clear and readable. 19 | - Allowed prefixes: breaking, deps, chore, ci, docs, feat, fix, perf, refactor, style, test 20 | - Commit messages should look like: 21 | ``` 22 | feat: I made a new feature 23 | 24 | This is where the optional body goes. 25 | 26 | This is where the optional footer goes. 27 | ``` 28 | 29 | ## Local Development 30 | 31 | ### Installation 32 | 1. Install python >=3.8 33 | 2. Create a python virtual environment 34 | 3. Install python dependencies 35 | ```shell 36 | make install-dev 37 | ``` 38 | 4. Install pre-commit 39 | ```shell 40 | make install-pre-commit 41 | ``` 42 | 43 | ### Running Tests 44 | ```shell 45 | make run-tests 46 | ``` 47 | 48 | ### Type Checking 49 | - Mypy (in strict mode) is used to type-check this project. 50 | - The "Any" type is discouraged, although sometimes necessary. 51 | - "ignore" directives are also discouraged. 52 | 53 | ```shell 54 | make type-check 55 | ``` 56 | 57 | ### Linting 58 | - Flake8 is used to lint this project. 59 | - This is ideally done via a pre-commit hook. Installation details are mentioned above. 60 | - "# flake8: noqa" directives are discouraged. 61 | 62 | Thanks for contributing! 63 | -------------------------------------------------------------------------------- /tests/test_with_tags.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from unittest import TestCase 4 | from examples.tags import app, versions, tags 5 | 6 | 7 | class TestTagsExample(TestCase): 8 | def setUp(self) -> None: 9 | self.maxDiff = None 10 | 11 | def test_tags_example(self) -> None: 12 | test_client = TestClient(app) 13 | 14 | self.assertListEqual([(1, 0), (2, 0)], versions) 15 | 16 | # Make sure some pages don't exist 17 | self.assertEqual(404, test_client.get('/redoc').status_code) 18 | self.assertEqual(404, test_client.get('/v1/redoc').status_code) 19 | self.assertEqual(404, test_client.get('/v2/redoc').status_code) 20 | self.assertEqual(404, test_client.get('/latest/redoc').status_code) 21 | self.assertEqual(404, test_client.post('/users').status_code) 22 | self.assertEqual(404, test_client.post('/items').status_code) 23 | self.assertEqual(404, test_client.get('/users').status_code) 24 | self.assertEqual(404, test_client.get('/items').status_code) 25 | self.assertEqual(404, test_client.get('/users/1').status_code) 26 | self.assertEqual(404, test_client.get('/items/1').status_code) 27 | self.assertEqual(404, test_client.get('/v2/items/1').status_code) 28 | self.assertEqual(404, test_client.get('/v1/versions').status_code) 29 | self.assertEqual(404, test_client.get('/v2/versions').status_code) 30 | self.assertEqual(404, test_client.get('/latest/versions').status_code) 31 | 32 | # versions route 33 | self.assertDictEqual( 34 | { 35 | 'versions': [ 36 | { 37 | 'version': '1', 38 | 'openapi_url': '/v1/api_schema.json', 39 | 'swagger_url': '/v1/swagger', 40 | }, 41 | { 42 | 'version': '2', 43 | 'openapi_url': '/v2/api_schema.json', 44 | 'swagger_url': '/v2/swagger', 45 | }, 46 | ] 47 | }, 48 | test_client.get('/versions').json(), 49 | ) 50 | 51 | # docs 52 | self.assertEqual(200, test_client.get('/swagger').status_code) 53 | self.assertEqual(200, test_client.get('/v1/swagger').status_code) 54 | self.assertEqual(200, test_client.get('/v2/swagger').status_code) 55 | self.assertEqual(200, test_client.get('/latest/swagger').status_code) 56 | 57 | tags_main = test_client.get('/api_schema.json').json().get('tags', {}) 58 | tags_v1 = test_client.get('/v1/api_schema.json').json().get('tags', {}) 59 | tags_v2 = test_client.get('/v2/api_schema.json').json().get('tags', None) 60 | tags_latest = test_client.get('/latest/api_schema.json').json().get('tags', None) 61 | 62 | # openapi 63 | self.assertListEqual(tags, tags_main) 64 | self.assertListEqual(tags, tags_v1) 65 | self.assertIsNone(tags_v2) 66 | self.assertIsNone(tags_latest) 67 | -------------------------------------------------------------------------------- /examples/tags.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="no-any-return" 2 | # flake8: noqa: A003 3 | 4 | from typing import List, Any, Dict 5 | from fastapi import FastAPI, APIRouter 6 | from pydantic import BaseModel 7 | 8 | from fastapi_versionizer.versionizer import Versionizer, api_version 9 | 10 | 11 | class User(BaseModel): 12 | id: int 13 | name: str 14 | 15 | 16 | class UserV2(BaseModel): 17 | id: int 18 | name: str 19 | age: int 20 | 21 | 22 | class Item(BaseModel): 23 | id: int 24 | name: str 25 | 26 | 27 | class ItemV2(BaseModel): 28 | id: int 29 | name: str 30 | cost: int 31 | 32 | 33 | class DB: 34 | def __init__(self) -> None: 35 | self.users: Dict[int, Any] = {} 36 | self.items: Dict[int, Any] = {} 37 | 38 | 39 | tags = [ 40 | { 41 | "name": "Items", 42 | "description": "Operations with **items**.", 43 | }, 44 | ] 45 | 46 | db = DB() 47 | app = FastAPI( 48 | title="test", 49 | docs_url="/swagger", 50 | openapi_url="/api_schema.json", 51 | redoc_url=None, 52 | description="Simple example of FastAPI Versionizer.", 53 | terms_of_service="https://github.com/alexschimpf/fastapi-versionizer", 54 | openapi_tags=tags, 55 | ) 56 | users_router = APIRouter(prefix="/users", tags=["Users"]) 57 | items_router = APIRouter(prefix="/items", tags=["Items"]) 58 | 59 | 60 | @app.get("/status", tags=["Status"]) 61 | def get_status() -> str: 62 | return "Ok" 63 | 64 | 65 | @api_version(1) 66 | @users_router.get("", deprecated=True) 67 | def get_users() -> List[User]: 68 | return list(db.users.values()) 69 | 70 | 71 | @api_version(1) 72 | @users_router.post("", deprecated=True) 73 | def create_user(user: User) -> User: 74 | db.users[user.id] = user 75 | return user 76 | 77 | 78 | @api_version(1) 79 | @users_router.get("/{user_id}", deprecated=True) 80 | def get_user(user_id: int) -> User: 81 | return db.users[user_id] 82 | 83 | 84 | @api_version(2) 85 | @users_router.get("") 86 | def get_users_v2() -> List[UserV2]: 87 | return list(user for user in db.users.values() if isinstance(user, UserV2)) 88 | 89 | 90 | @api_version(2) 91 | @users_router.post("") 92 | def create_user_v2(user: UserV2) -> UserV2: 93 | db.users[user.id] = user 94 | return user 95 | 96 | 97 | @api_version(2) 98 | @users_router.get("/{user_id}") 99 | def get_user_v2(user_id: int) -> UserV2: 100 | return db.users[user_id] 101 | 102 | 103 | @api_version(1, remove_in_major=2) 104 | @items_router.get("") 105 | def get_items() -> List[Item]: 106 | return list(db.items.values()) 107 | 108 | 109 | @api_version(1, remove_in_major=2) 110 | @items_router.post("") 111 | def create_item(item: Item) -> Item: 112 | db.items[item.id] = item 113 | return item 114 | 115 | 116 | @api_version(1, remove_in_major=2) 117 | @items_router.get("/{item_id}") 118 | def get_item(item_id: int) -> Item: 119 | return db.items[item_id] 120 | 121 | 122 | app.include_router(users_router) 123 | app.include_router(items_router) 124 | 125 | versions = Versionizer( 126 | app=app, 127 | prefix_format="/v{major}", 128 | semantic_version_format="{major}", 129 | latest_prefix="/latest", 130 | include_versions_route=True, 131 | sort_routes=True, 132 | ).versionize() 133 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="no-any-return" 2 | # flake8: noqa: A003 3 | 4 | from typing import List, Any, Dict 5 | from fastapi import FastAPI, APIRouter 6 | from pydantic import BaseModel 7 | 8 | from fastapi_versionizer.versionizer import Versionizer, api_version 9 | 10 | 11 | class User(BaseModel): 12 | id: int 13 | name: str 14 | 15 | 16 | class UserV2(BaseModel): 17 | id: int 18 | name: str 19 | age: int 20 | 21 | 22 | class Item(BaseModel): 23 | id: int 24 | name: str 25 | 26 | 27 | class ItemV2(BaseModel): 28 | id: int 29 | name: str 30 | cost: int 31 | 32 | 33 | class DB: 34 | def __init__(self) -> None: 35 | self.users: Dict[int, Any] = {} 36 | self.items: Dict[int, Any] = {} 37 | 38 | 39 | db = DB() 40 | app = FastAPI( 41 | title='test', 42 | docs_url='/swagger', 43 | openapi_url='/api_schema.json', 44 | redoc_url=None, 45 | description='Simple example of FastAPI Versionizer.', 46 | terms_of_service='https://github.com/alexschimpf/fastapi-versionizer' 47 | ) 48 | users_router = APIRouter( 49 | prefix='/users', 50 | tags=['Users'] 51 | ) 52 | items_router = APIRouter( 53 | prefix='/items', 54 | tags=['Items'] 55 | ) 56 | 57 | 58 | @app.get('/status', tags=['Status']) 59 | def get_status() -> str: 60 | return 'Ok' 61 | 62 | 63 | @api_version(1) 64 | @users_router.get('', deprecated=True) 65 | def get_users() -> List[User]: 66 | return list(db.users.values()) 67 | 68 | 69 | @api_version(1) 70 | @users_router.post('', deprecated=True) 71 | def create_user(user: User) -> User: 72 | db.users[user.id] = user 73 | return user 74 | 75 | 76 | @api_version(1) 77 | @users_router.get('/{user_id}', deprecated=True) 78 | def get_user(user_id: int) -> User: 79 | return db.users[user_id] 80 | 81 | 82 | @api_version(2) 83 | @users_router.get('') 84 | def get_users_v2() -> List[UserV2]: 85 | return list(user for user in db.users.values() if isinstance(user, UserV2)) 86 | 87 | 88 | @api_version(2) 89 | @users_router.post('') 90 | def create_user_v2(user: UserV2) -> UserV2: 91 | db.users[user.id] = user 92 | return user 93 | 94 | 95 | @api_version(2) 96 | @users_router.get('/{user_id}') 97 | def get_user_v2(user_id: int) -> UserV2: 98 | return db.users[user_id] 99 | 100 | 101 | @api_version(1) 102 | @items_router.get('', deprecated=True) 103 | def get_items() -> List[Item]: 104 | return list(db.items.values()) 105 | 106 | 107 | @api_version(1) 108 | @items_router.post('', deprecated=True) 109 | def create_item(item: Item) -> Item: 110 | db.items[item.id] = item 111 | return item 112 | 113 | 114 | @api_version(1, remove_in_major=2) 115 | @items_router.get('/{item_id}', deprecated=True) 116 | def get_item(item_id: int) -> Item: 117 | return db.items[item_id] 118 | 119 | 120 | @api_version(2) 121 | @items_router.get('') 122 | def get_items_v2() -> List[ItemV2]: 123 | return list(item for item in db.items.values() if isinstance(item, ItemV2)) 124 | 125 | 126 | @api_version(2) 127 | @items_router.post('') 128 | def create_item_v2(item: ItemV2) -> ItemV2: 129 | db.items[item.id] = item 130 | return item 131 | 132 | 133 | app.include_router(users_router) 134 | app.include_router(items_router) 135 | 136 | versions = Versionizer( 137 | app=app, 138 | prefix_format='/v{major}', 139 | semantic_version_format='{major}', 140 | latest_prefix='/latest', 141 | include_versions_route=True, 142 | sort_routes=True 143 | ).versionize() 144 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "preset": "angular", 10 | "releaseRules": [ 11 | { 12 | "type": "breaking", 13 | "release": "major" 14 | }, 15 | { 16 | "type": "feat", 17 | "release": "minor" 18 | }, 19 | { 20 | "type": "fix", 21 | "release": "patch" 22 | }, 23 | { 24 | "type": "perf", 25 | "release": "patch" 26 | }, 27 | { 28 | "type": "deps", 29 | "release": "patch" 30 | }, 31 | { 32 | "type": "refactor", 33 | "release": false 34 | }, 35 | { 36 | "type": "docs", 37 | "release": false 38 | }, 39 | { 40 | "type": "style", 41 | "release": false 42 | }, 43 | { 44 | "type": "test", 45 | "release": false 46 | }, 47 | { 48 | "type": "ci", 49 | "release": false 50 | }, 51 | { 52 | "type": "chore", 53 | "release": false 54 | } 55 | ] 56 | } 57 | ], 58 | [ 59 | "@semantic-release/release-notes-generator", 60 | { 61 | "preset": "conventionalcommits", 62 | "presetConfig": { 63 | "types": [ 64 | { 65 | "type": "breaking", 66 | "section": "Breaking Changes" 67 | }, 68 | { 69 | "type": "feat", 70 | "section": "Features" 71 | }, 72 | { 73 | "type": "fix", 74 | "section": "Bug Fixes" 75 | }, 76 | { 77 | "type": "perf", 78 | "section": "Performance Improvements" 79 | } 80 | ] 81 | } 82 | } 83 | ], 84 | [ 85 | "@semantic-release/changelog", 86 | { 87 | "changelogFile": "CHANGELOG.md" 88 | } 89 | ], 90 | [ 91 | "@semantic-release/npm", 92 | { 93 | "npmPublish": false 94 | } 95 | ], 96 | [ 97 | "@semantic-release/git", 98 | { 99 | "message": "chore: Release ${nextRelease.version}", 100 | "assets": [ 101 | "CHANGELOG.md", 102 | "package.json" 103 | ] 104 | } 105 | ], 106 | "@semantic-release/github" 107 | ] 108 | } 109 | -------------------------------------------------------------------------------- /examples/docs_customization.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="no-any-return" 2 | # flake8: noqa: A003 3 | 4 | from fastapi import FastAPI, APIRouter, Depends 5 | from fastapi.openapi.docs import get_swagger_ui_html 6 | from fastapi.openapi.docs import get_redoc_html 7 | import fastapi.openapi.utils 8 | from fastapi.responses import HTMLResponse 9 | from fastapi.security import HTTPBasic, HTTPBasicCredentials 10 | from typing import Any, Tuple 11 | 12 | from fastapi_versionizer.versionizer import Versionizer, api_version 13 | 14 | ICON_URL = 'https://avatars.githubusercontent.com/u/6480668?s=400&u=22411d8d949f102698c06b8c49b75f2a2827cb5e&v=4' 15 | USERNAME = 'test' 16 | PASSWORD = 'secret!' 17 | 18 | security = HTTPBasic() 19 | app = FastAPI( 20 | title='test' 21 | ) 22 | 23 | 24 | @api_version(1, 0) 25 | @app.get('/status', tags=['Status']) 26 | def get_status() -> str: 27 | return 'Ok - 1.0' 28 | 29 | 30 | @api_version(1, 0, remove_in_major=2) 31 | @app.get('/deps', tags=['Deps']) 32 | def get_deps() -> str: 33 | return 'Ok' 34 | 35 | 36 | @api_version(2, 0) 37 | @app.get('/status', tags=['Status']) 38 | def get_status_v2() -> str: 39 | return 'Ok - 2.0' 40 | 41 | 42 | def callback(router: APIRouter, version: Tuple[int, int], version_prefix: str) -> None: 43 | title = f'test - {".".join(map(str, version))}' if version_prefix else 'test' 44 | 45 | @router.get('/openapi.json', include_in_schema=False) 46 | async def get_openapi() -> Any: 47 | openapi_schema = fastapi.openapi.utils.get_openapi( 48 | title=title, 49 | version=version_prefix[1:], 50 | routes=router.routes 51 | ) 52 | 53 | # Change 200 response description 54 | for schema_path in openapi_schema['paths']: 55 | for method in openapi_schema['paths'][schema_path]: 56 | openapi_schema['paths'][schema_path][method]['responses']['200']['description'] = 'Success!' 57 | 58 | return openapi_schema 59 | 60 | @router.get('/docs', include_in_schema=False) 61 | async def get_docs(credentials: HTTPBasicCredentials = Depends(security)) -> Any: 62 | if credentials.username != USERNAME or credentials.password != PASSWORD: 63 | raise Exception('Invalid username/password') 64 | 65 | return get_swagger_ui_html( 66 | openapi_url=f'{version_prefix}/openapi.json', 67 | title=title, 68 | swagger_ui_parameters={ 69 | 'defaultModelsExpandDepth': -1 70 | }, 71 | swagger_favicon_url=ICON_URL 72 | ) 73 | 74 | @router.get('/redoc', include_in_schema=False) 75 | async def get_redoc(credentials: HTTPBasicCredentials = Depends(security)) -> HTMLResponse: 76 | if credentials.username != USERNAME or credentials.password != PASSWORD: 77 | raise Exception('Invalid username/password') 78 | 79 | return get_redoc_html( 80 | openapi_url=f'{version_prefix}/openapi.json', 81 | title=title, 82 | redoc_favicon_url=ICON_URL 83 | ) 84 | 85 | 86 | versions = Versionizer( 87 | app=app, 88 | prefix_format='/v{major}_{minor}', 89 | semantic_version_format='{major}.{minor}', 90 | latest_prefix='/latest', 91 | sort_routes=True, 92 | include_main_docs=False, 93 | include_version_docs=False, 94 | include_version_openapi_route=False, 95 | include_main_openapi_route=False, 96 | callback=callback 97 | ).versionize() 98 | 99 | 100 | # Add main docs pages, with all versioned routes 101 | callback(app.router, (2, 0), '') 102 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 4.0.3 (2025-08-03) 2 | 3 | * fix: Fix README.md pypi rendering by adding a new line (#73) ([ed720ca](https://github.com/alexschimpf/fastapi-versionizer/commit/ed720ca)), closes [#73](https://github.com/alexschimpf/fastapi-versionizer/issues/73) 4 | 5 | ## 4.0.2 (2025-08-02) 6 | 7 | * fix: Fix release workflow (#70) ([1f05f44](https://github.com/alexschimpf/fastapi-versionizer/commit/1f05f44)), closes [#70](https://github.com/alexschimpf/fastapi-versionizer/issues/70) 8 | * fix: Update pnpm dependencies lock file (#71) ([8d87636](https://github.com/alexschimpf/fastapi-versionizer/commit/8d87636)), closes [#71](https://github.com/alexschimpf/fastapi-versionizer/issues/71) 9 | * fix (compatibility): Python3.13 compatibility (#63) ([3025a3a](https://github.com/alexschimpf/fastapi-versionizer/commit/3025a3a)), closes [#63](https://github.com/alexschimpf/fastapi-versionizer/issues/63) 10 | * chore: Update pnpm version and dependencies' versions (#67) ([8c15032](https://github.com/alexschimpf/fastapi-versionizer/commit/8c15032)), closes [#67](https://github.com/alexschimpf/fastapi-versionizer/issues/67) 11 | * chore: update release action to dispatch and deploy to "on release" (#68) ([eeb1c5e](https://github.com/alexschimpf/fastapi-versionizer/commit/eeb1c5e)), closes [#68](https://github.com/alexschimpf/fastapi-versionizer/issues/68) 12 | * docs: Document static file mount gotcha ([f92f054](https://github.com/alexschimpf/fastapi-versionizer/commit/f92f054)) 13 | 14 | ## [4.0.1](https://github.com/alexschimpf/fastapi-versionizer/compare/v4.0.0...v4.0.1) (2024-03-05) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * Fixed oauth2 redirect bug for versioned routes ([17f0172](https://github.com/alexschimpf/fastapi-versionizer/commit/17f0172bf1a6cf4d4f39f94107307557facbf4ff)) 20 | 21 | ## [4.0.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v3.0.4...v4.0.0) (2024-03-05) 22 | 23 | 24 | ### Breaking Changes 25 | 26 | * Dropped support for python 3.7 ([00607b8](https://github.com/alexschimpf/fastapi-versionizer/commit/00607b8f1ae0db23b7e63666b0629307a3a631dc)) 27 | 28 | ## [3.0.4](https://github.com/alexschimpf/fastapi-versionizer/compare/v3.0.3...v3.0.4) (2023-11-03) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * Added Websocket support back ([211a66e](https://github.com/alexschimpf/fastapi-versionizer/commit/211a66e8aac56dbf2d5ffc94d6c65959044ca5dd)) 34 | 35 | ## [3.0.3](https://github.com/alexschimpf/fastapi-versionizer/compare/v3.0.2...v3.0.3) (2023-11-02) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * Fixed issue with OpenAPI tags for versioned docs ([dbc434c](https://github.com/alexschimpf/fastapi-versionizer/commit/dbc434c85170cbc1802ff167e33c8ab4204d64d3)) 41 | 42 | ## [3.0.2](https://github.com/alexschimpf/fastapi-versionizer/compare/v3.0.1...v3.0.2) (2023-10-25) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * Fixed OAuth issue for versioned Swagger pages ([94b7a37](https://github.com/alexschimpf/fastapi-versionizer/commit/94b7a37de66a3fe5304d26460371788f38c308ef)) 48 | 49 | ## [3.0.1](https://github.com/alexschimpf/fastapi-versionizer/compare/v3.0.0...v3.0.1) (2023-10-17) 50 | 51 | 52 | ### Bug Fixes 53 | 54 | * Fixed issue with root_path/servers in versioned doc pages ([a82a343](https://github.com/alexschimpf/fastapi-versionizer/commit/a82a343de350b7a323a8a46b023b1dc897c1302b)) 55 | 56 | ## [3.0.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.5...v3.0.0) (2023-10-14) 57 | 58 | 59 | ### Breaking Changes 60 | 61 | * Versionizer now versions a FastAPI app in place ([8217e80](https://github.com/alexschimpf/fastapi-versionizer/commit/8217e80b3925a7d30ef77e6eb8693b271fe02247)) 62 | 63 | ## [2.1.5](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.4...v2.1.5) (2023-10-14) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * Fixed issue with middleware stack support ([e96cf0d](https://github.com/alexschimpf/fastapi-versionizer/commit/e96cf0d004d20a65668d85f5ae46d427d958f5ef)) 69 | 70 | ## [2.1.4](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.3...v2.1.4) (2023-10-13) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * Fixed issue with OpenAPI metadata not showing up for versioned doc pages ([87093b9](https://github.com/alexschimpf/fastapi-versionizer/commit/87093b95766efa0bbc49777fae75efc55e489747)) 76 | 77 | ## [2.1.3](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.2...v2.1.3) (2023-10-13) 78 | 79 | 80 | ### Bug Fixes 81 | 82 | * Fixed issue with lifespan support ([ee40d11](https://github.com/alexschimpf/fastapi-versionizer/commit/ee40d11cba743c07216370715a7fbcd23f0a145e)) 83 | 84 | ## [2.1.2](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.1...v2.1.2) (2023-10-04) 85 | 86 | 87 | ### Bug Fixes 88 | 89 | * Fixed issue where custom latest_prefix wasn't getting passed to callback correctly ([020d32b](https://github.com/alexschimpf/fastapi-versionizer/commit/020d32b13143c1a6d98b449fec17cf23d0d8ed86)) 90 | 91 | ## [2.1.1](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.1.0...v2.1.1) (2023-10-03) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * Now using app's openapi_url field for OpenAPI URL ([e97d5f9](https://github.com/alexschimpf/fastapi-versionizer/commit/e97d5f95eb6b8d006c03fff0bfbfd8136c1b2eec)) 97 | 98 | ## [2.1.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v2.0.0...v2.1.0) (2023-10-03) 99 | 100 | 101 | ### Features 102 | 103 | * Added include_versions_route parameter ([42dcaf7](https://github.com/alexschimpf/fastapi-versionizer/commit/42dcaf73bf2bff7d6b6d734c8c30137b73aa6f06)) 104 | 105 | ## [2.0.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v1.2.0...v2.0.0) (2023-10-02) 106 | 107 | 108 | ### Breaking Changes 109 | 110 | * Redesigned FastAPI versionizer ([4cd0fd1](https://github.com/alexschimpf/fastapi-versionizer/commit/4cd0fd1d3e93eb1845439743ed907d562a508bb9)) 111 | 112 | ## [1.2.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v1.1.1...v1.2.0) (2023-09-28) 113 | 114 | 115 | ### Features 116 | 117 | * Added enable_versions_route param ([e3afcce](https://github.com/alexschimpf/fastapi-versionizer/commit/e3afcce98b9422dc3f54d722fc9168030e1c7e75)) 118 | 119 | ## [1.1.1](https://github.com/alexschimpf/fastapi-versionizer/compare/v1.1.0...v1.1.1) (2023-09-28) 120 | 121 | 122 | ### Bug Fixes 123 | 124 | * Now using natural sorting for routes for sorted_routes=True ([d55af27](https://github.com/alexschimpf/fastapi-versionizer/commit/d55af275bbc5e55c7ee203b04aeff65e09893c93)) 125 | 126 | ## [1.1.0](https://github.com/alexschimpf/fastapi-versionizer/compare/v1.0.1...v1.1.0) (2023-09-27) 127 | 128 | 129 | ### Features 130 | 131 | * Added callback feature ([43a8e77](https://github.com/alexschimpf/fastapi-versionizer/commit/43a8e77eb1cf57ec00385a4ee5bfd3751e1fc9a0)) 132 | 133 | ## [1.0.1](https://github.com/alexschimpf/fastapi-versionizer/compare/v1.0.0...v1.0.1) (2023-09-20) 134 | 135 | 136 | ### Bug Fixes 137 | 138 | * Dummy commit to bump version ([020f393](https://github.com/alexschimpf/fastapi-versionizer/commit/020f3936f3cf101c2a7c0171ce6c656bca9993cf)) 139 | 140 | ## 1.0.0 (2023-09-20) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * Fixed package build error ([2f08334](https://github.com/alexschimpf/fastapi-versionizer/commit/2f083343b5a51c7ea3a0a10747250c4c123840c6)) 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Versionizer 2 | 3 | ## Credit 4 | This was inspired by [fastapi_versioning](https://github.com/DeanWay/fastapi-versioning). 5 | This project addresses issues with `fastapi_versioning` and adds some additional features. 6 | 7 | ## Installation 8 | `pip install fastapi-versionizer` 9 | 10 | ## Examples 11 | You can find examples in the [examples](https://github.com/alexschimpf/fastapi-versionizer/tree/main/examples) directory. 12 | 13 | ## Summary 14 | FastAPI Versionizer makes API versioning easy. 15 | 16 | Here is a simple (and rather contrived) example: 17 | 18 | ```python 19 | from typing import List 20 | from fastapi import FastAPI, APIRouter 21 | from pydantic import BaseModel 22 | 23 | from fastapi_versionizer.versionizer import Versionizer, api_version 24 | 25 | 26 | class User(BaseModel): 27 | id: int 28 | name: str 29 | 30 | 31 | class UserV2(BaseModel): 32 | id: int 33 | name: str 34 | age: int 35 | 36 | 37 | db = { 38 | 'users': {} 39 | } 40 | app = FastAPI( 41 | title='test', 42 | redoc_url=None 43 | ) 44 | users_router = APIRouter( 45 | prefix='/users', 46 | tags=['Users'] 47 | ) 48 | 49 | 50 | @app.get('/status', tags=['Status']) 51 | def get_status() -> str: 52 | return 'Ok' 53 | 54 | 55 | @api_version(1) 56 | @users_router.get('', deprecated=True) 57 | def get_users() -> List[User]: 58 | return list(user for user in db['users'].values() if isinstance(user, User)) 59 | 60 | 61 | @api_version(1) 62 | @users_router.post('', deprecated=True) 63 | def create_user(user: User) -> User: 64 | db['users'][user.id] = user 65 | return user 66 | 67 | 68 | @api_version(2) 69 | @users_router.get('') 70 | def get_users_v2() -> List[UserV2]: 71 | return list(user for user in db['users'].values() if isinstance(user, UserV2)) 72 | 73 | 74 | @api_version(2) 75 | @users_router.post('') 76 | def create_user_v2(user: UserV2) -> UserV2: 77 | db['users'][user.id] = user 78 | return user 79 | 80 | 81 | app.include_router(users_router) 82 | 83 | versions = Versionizer( 84 | app=app, 85 | prefix_format='/v{major}', 86 | semantic_version_format='{major}', 87 | latest_prefix='/latest', 88 | sort_routes=True 89 | ).versionize() 90 | ``` 91 | 92 | This will generate the following endpoints: 93 | - GET /openapi.json 94 | - OpenAPI schema with endpoints from all versions 95 | - GET /docs 96 | - Swagger page with endpoints from all versions 97 | - GET /v1/openapi.json 98 | - OpenAPI schema for v1 endpoints 99 | - GET /v1/docs 100 | - Swagger page for v1 endpoints 101 | - GET /v1/status 102 | - GET /v1/users 103 | - POST /v1/users 104 | - GET /v2/openapi.json 105 | - OpenAPI schema for v2 endpoints 106 | - GET /v2/docs 107 | - Swagger page for v2 endpoints 108 | - GET /v2/status 109 | - This gets carried on from v1, where it was introduced, but has the same implementation 110 | - GET /v2/users 111 | - POST /v2/users 112 | - GET /latest/openapi.json 113 | - OpenAPI schema for latest (i.e. v2) endpoints 114 | - GET /latest/docs 115 | - Swagger page for latest (i.e. v2) endpoints 116 | - GET /latest/status 117 | - GET /latest/users 118 | - POST /latest/users 119 | 120 | ## Details 121 | FastAPI Versionizer works by modifying a FastAPI app in place, adding versioned routes and proper docs pages. 122 | Routes are annotated with version information, using the `@api_version` decorator. 123 | Using this decorator, you can specify the version (major and/or minor) that the route was introduced. 124 | You can also specify the first version when the route should be considered deprecated or even removed. 125 | Each new version will include all routes from previous versions that have not been overridden or marked for removal. 126 | An APIRouter will be created for each version, with the URL prefix defined by the `prefix_format` parameter described below, 127 | 128 | ## Versionizer Parameters 129 | - app 130 | - The FastAPI you want to version 131 | - prefix_format 132 | - Used to build the version path prefix for routes. 133 | - It should contain either "{major}" or "{minor}" or both. 134 | - Examples: "/v{major}", "/v{major}_{minor}" 135 | - semantic_version_format 136 | - Used to build the semantic version, which is shown in docs. 137 | - Examples: "{major}", "{major}.{minor}" 138 | - default_version 139 | - Default version used if a route is not annotated with @api_version. 140 | - latest_prefix 141 | - If this is given, the routes in your latest version will be a given a separate prefix alias. 142 | - For example, if latest_prefix='latest', latest version is 1, and you have routes: "GET /v1/a" and "POST /v1/b", then "GET /latest/a" and "POST /latest/b" will also be added. 143 | - include_main_docs 144 | - If True, docs page(s) will be created at the root, with all versioned routes included 145 | - include_main_openapi_route 146 | - If True, an OpenAPI route will be created at the root, with all versioned routes included 147 | - include_version_docs 148 | - If True, docs page(s) will be created for each version 149 | - include_version_openapi_route 150 | - If True, an OpenAPI route will be created for each version 151 | - include_versions_route 152 | - If True, a "GET /versions" route will be added, which includes information about all API versions 153 | - sort_routes 154 | - If True, all routes will be naturally sorted by path within each version. 155 | - If you have included the main docs page, the routes are sorted within each version, and versions are sorted from earliest to latest. If you have added a "latest" alias, its routes will be listed last. 156 | - callback 157 | - A function that is called each time a version router is created and all its routes have been added. 158 | - It is called before the router has been added to the root FastAPI app. 159 | - This function should not return anything and has the following parameters: 160 | - Version router 161 | - Version (in tuple form) 162 | - Version path prefix 163 | 164 | ## Docs Customization 165 | - There are various parameters mentioned above for controlling which docs page are generated. 166 | - The swagger and redoc URL paths can be controlled by setting your FastAPI app's `docs_url` and `redoc_url`. 167 | - If these are set to None, docs pages will not be generated. 168 | - Swagger UI parameters can be controlled by setting your FastAPI app's `swagger_ui_parameters` 169 | - If you want to customize your docs beyond what FastAPI Versionizer can handle, you can do the following: 170 | - Set `include_main_docs` and `include_version_docs` to False 171 | - Set `include_main_openapi_route` and `include_version_openapi_route` to False if you need to customize the OpenAPI schema. 172 | - Pass a `callback` param to `Versionizer` and add your own docs/OpenAPI routes manually for each version 173 | - If you want a "main" docs page, with all versioned routes included, you can manually add a docs/OpenAPI route to the versioned FastAPI app returned by `Versionizer.versionize()`. 174 | - See the [Docs Customization](https://github.com/alexschimpf/fastapi-versionizer/tree/main/examples/docs_customization.py) example for more details 175 | 176 | ## Gotchas 177 | 178 | ### Static file mounts 179 | 180 | If you need to [mount static files](https://fastapi.tiangolo.com/tutorial/static-files/), you'll have to add those to 181 | your FastAPI app **after** instantiating Versionizer. See the [Static file mount](https://github.com/alexschimpf/fastapi-versionizer/tree/main/examples/with_static_file_mount.py) 182 | example for more details. 183 | -------------------------------------------------------------------------------- /tests/test_with_root_path.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | import pydantic 3 | from fastapi.testclient import TestClient 4 | 5 | from unittest import TestCase 6 | from examples.with_root_path import app, versions 7 | 8 | 9 | class TestWitRootPathExample(TestCase): 10 | 11 | def setUp(self) -> None: 12 | self.maxDiff = None 13 | 14 | def test_with_root_path_example(self) -> None: 15 | test_client = TestClient(app) 16 | 17 | self.assertListEqual([(1, 0), (2, 0)], versions) 18 | 19 | # versions route 20 | self.assertDictEqual( 21 | { 22 | 'versions': [ 23 | { 24 | 'version': '1', 25 | 'openapi_url': '/api/v1/api_schema.json', 26 | 'swagger_url': '/api/v1/swagger' 27 | }, 28 | { 29 | 'version': '2', 30 | 'openapi_url': '/api/v2/api_schema.json', 31 | 'swagger_url': '/api/v2/swagger', 32 | } 33 | ] 34 | }, 35 | test_client.get('/versions').json() 36 | ) 37 | 38 | self.assertEqual('"Okv1"', test_client.get('/v1/status').text) 39 | self.assertEqual('"Okv2"', test_client.get('/v2/status').text) 40 | self.assertEqual('"Okv2"', test_client.get('/latest/status').text) 41 | 42 | # docs 43 | self.assertEqual(200, test_client.get('/swagger').status_code) 44 | self.assertEqual(200, test_client.get('/v1/swagger').status_code) 45 | self.assertEqual(200, test_client.get('/v2/swagger').status_code) 46 | self.assertEqual(200, test_client.get('/latest/swagger').status_code) 47 | 48 | # openapi 49 | expected_response: Dict[str, Any] = { 50 | 'openapi': '3.1.0', 51 | 'info': { 52 | 'title': 'test', 53 | 'version': '0.1.0' 54 | }, 55 | 'servers': [ 56 | { 57 | 'url': '/api' 58 | } 59 | ], 60 | 'paths': { 61 | '/v1/status': { 62 | 'get': { 63 | 'tags': [ 64 | 'Status' 65 | ], 66 | 'summary': 'Get Status V1', 67 | 'operationId': 'get_status_v1_v1_status_get', 68 | 'responses': { 69 | '200': { 70 | 'description': 'Successful Response', 71 | 'content': { 72 | 'application/json': { 73 | 'schema': { 74 | 'type': 'string', 75 | 'title': 'Response Get Status V1 V1 Status Get' 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | '/v2/status': { 84 | 'get': { 85 | 'tags': [ 86 | 'Status' 87 | ], 88 | 'summary': 'Get Status V2', 89 | 'operationId': 'get_status_v2_v2_status_get', 90 | 'responses': { 91 | '200': { 92 | 'description': 'Successful Response', 93 | 'content': { 94 | 'application/json': { 95 | 'schema': { 96 | 'type': 'string', 97 | 'title': 'Response Get Status V2 V2 Status Get' 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | }, 105 | '/latest/status': { 106 | 'get': { 107 | 'tags': [ 108 | 'Status' 109 | ], 110 | 'summary': 'Get Status V2', 111 | 'operationId': 'get_status_v2_latest_status_get', 112 | 'responses': { 113 | '200': { 114 | 'description': 'Successful Response', 115 | 'content': { 116 | 'application/json': { 117 | 'schema': { 118 | 'type': 'string', 119 | 'title': 'Response Get Status V2 Latest Status Get' 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | }, 127 | '/versions': { 128 | 'get': { 129 | 'tags': [ 130 | 'Versions' 131 | ], 132 | 'summary': 'Get Versions', 133 | 'operationId': 'get_versions_versions_get', 134 | 'responses': { 135 | '200': { 136 | 'description': 'Successful Response', 137 | 'content': { 138 | 'application/json': { 139 | 'schema': { 140 | 'type': 'object', 141 | 'title': 'Response Get Versions Versions Get' 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | if pydantic.__version__ >= '2.11.0': 152 | # added 'additionalProperties': True in the /versions API 153 | expected_response['paths']['/versions']['get']['responses']['200'][ 154 | 'content' 155 | ]['application/json']['schema']['additionalProperties'] = True 156 | self.assertDictEqual( 157 | expected_response, 158 | test_client.get('/api/api_schema.json').json() 159 | ) 160 | self.assertDictEqual( 161 | { 162 | 'openapi': '3.1.0', 163 | 'info': { 164 | 'title': 'test - v1', 165 | 'version': 'v1' 166 | }, 167 | 'servers': [ 168 | { 169 | 'url': '/api' 170 | } 171 | ], 172 | 'paths': { 173 | '/v1/status': { 174 | 'get': { 175 | 'tags': [ 176 | 'Status' 177 | ], 178 | 'summary': 'Get Status V1', 179 | 'operationId': 'get_status_v1_v1_status_get', 180 | 'responses': { 181 | '200': { 182 | 'description': 'Successful Response', 183 | 'content': { 184 | 'application/json': { 185 | 'schema': { 186 | 'type': 'string', 187 | 'title': 'Response Get Status V1 V1 Status Get' 188 | } 189 | } 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | }, 197 | test_client.get('/v1/api_schema.json').json() 198 | ) 199 | self.assertDictEqual( 200 | { 201 | 'openapi': '3.1.0', 202 | 'info': { 203 | 'title': 'test - v2', 204 | 'version': 'v2' 205 | }, 206 | 'servers': [ 207 | { 208 | 'url': '/api' 209 | } 210 | ], 211 | 'paths': { 212 | '/v2/status': { 213 | 'get': { 214 | 'tags': [ 215 | 'Status' 216 | ], 217 | 'summary': 'Get Status V2', 218 | 'operationId': 'get_status_v2_v2_status_get', 219 | 'responses': { 220 | '200': { 221 | 'description': 'Successful Response', 222 | 'content': { 223 | 'application/json': { 224 | 'schema': { 225 | 'type': 'string', 226 | 'title': 'Response Get Status V2 V2 Status Get' 227 | } 228 | } 229 | } 230 | } 231 | } 232 | } 233 | } 234 | } 235 | }, 236 | test_client.get('/v2/api_schema.json').json() 237 | ) 238 | self.assertDictEqual( 239 | { 240 | 'openapi': '3.1.0', 241 | 'info': { 242 | 'title': 'test - v2', 243 | 'version': 'v2' 244 | }, 245 | 'servers': [ 246 | { 247 | 'url': '/api' 248 | } 249 | ], 250 | 'paths': { 251 | '/latest/status': { 252 | 'get': { 253 | 'tags': [ 254 | 'Status' 255 | ], 256 | 'summary': 'Get Status V2', 257 | 'operationId': 'get_status_v2_latest_status_get', 258 | 'responses': { 259 | '200': { 260 | 'description': 'Successful Response', 261 | 'content': { 262 | 'application/json': { 263 | 'schema': { 264 | 'type': 'string', 265 | 'title': 'Response Get Status V2 Latest Status Get' 266 | } 267 | } 268 | } 269 | } 270 | } 271 | } 272 | } 273 | } 274 | }, 275 | test_client.get('/latest/api_schema.json').json() 276 | ) 277 | -------------------------------------------------------------------------------- /tests/test_docs_customization.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from unittest import TestCase 4 | from examples.docs_customization import app, versions 5 | 6 | 7 | class TestDocsCustomizationExample(TestCase): 8 | 9 | def setUp(self) -> None: 10 | self.maxDiff = None 11 | 12 | def test_docs_customization_example(self) -> None: 13 | test_client = TestClient(app) 14 | 15 | correct_headers = {'Authorization': 'Basic dGVzdDpzZWNyZXQh'} 16 | incorrect_headers = {'Authorization': 'Basic incorrect'} 17 | 18 | self.assertListEqual([(1, 0), (2, 0)], versions) 19 | 20 | # Make sure some pages don't exist 21 | self.assertEqual(404, test_client.get('/deps').status_code) 22 | self.assertEqual(404, test_client.get('/status').status_code) 23 | self.assertEqual(404, test_client.get('/v2_0/deps').status_code) 24 | self.assertEqual(404, test_client.get('/latest/deps').status_code) 25 | 26 | # v1.0 27 | self.assertEqual( 28 | '"Ok - 1.0"', 29 | test_client.get('/v1_0/status').text 30 | ) 31 | self.assertEqual( 32 | '"Ok"', 33 | test_client.get('/v1_0/deps').text 34 | ) 35 | 36 | # v2.0 37 | self.assertEqual( 38 | '"Ok - 2.0"', 39 | test_client.get('/v2_0/status').text 40 | ) 41 | 42 | # latest 43 | self.assertEqual( 44 | '"Ok - 2.0"', 45 | test_client.get('/latest/status').text 46 | ) 47 | 48 | # docs 49 | self.assertEqual(401, test_client.get('/docs', headers=incorrect_headers).status_code) 50 | self.assertEqual(401, test_client.get('/redoc', headers=incorrect_headers).status_code) 51 | self.assertEqual(401, test_client.get('/v1_0/docs', headers=incorrect_headers).status_code) 52 | self.assertEqual(401, test_client.get('/v1_0/redoc', headers=incorrect_headers).status_code) 53 | self.assertEqual(401, test_client.get('/v2_0/docs', headers=incorrect_headers).status_code) 54 | self.assertEqual(401, test_client.get('/v2_0/redoc', headers=incorrect_headers).status_code) 55 | self.assertEqual(401, test_client.get('/latest/docs', headers=incorrect_headers).status_code) 56 | self.assertEqual(401, test_client.get('/latest/redoc', headers=incorrect_headers).status_code) 57 | self.assertEqual(200, test_client.get('/docs', headers=correct_headers).status_code) 58 | self.assertEqual(200, test_client.get('/redoc', headers=correct_headers).status_code) 59 | self.assertEqual(200, test_client.get('/v1_0/docs', headers=correct_headers).status_code) 60 | self.assertEqual(200, test_client.get('/v1_0/redoc', headers=correct_headers).status_code) 61 | self.assertEqual(200, test_client.get('/v2_0/docs', headers=correct_headers).status_code) 62 | self.assertEqual(200, test_client.get('/v2_0/redoc', headers=correct_headers).status_code) 63 | self.assertEqual(200, test_client.get('/latest/docs', headers=correct_headers).status_code) 64 | self.assertEqual(200, test_client.get('/latest/redoc', headers=correct_headers).status_code) 65 | 66 | # openapi 67 | self.assertDictEqual( 68 | { 69 | 'openapi': '3.1.0', 70 | 'info': { 71 | 'title': 'test', 72 | 'version': '' 73 | }, 74 | 'paths': { 75 | '/v1_0/deps': { 76 | 'get': { 77 | 'tags': [ 78 | 'Deps' 79 | ], 80 | 'summary': 'Get Deps', 81 | 'operationId': 'get_deps_v1_0_deps_get', 82 | 'responses': { 83 | '200': { 84 | 'description': 'Success!', 85 | 'content': { 86 | 'application/json': { 87 | 'schema': { 88 | 'type': 'string', 89 | 'title': 'Response Get Deps V1 0 Deps Get' 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | }, 97 | '/v1_0/status': { 98 | 'get': { 99 | 'tags': [ 100 | 'Status' 101 | ], 102 | 'summary': 'Get Status', 103 | 'operationId': 'get_status_v1_0_status_get', 104 | 'responses': { 105 | '200': { 106 | 'description': 'Success!', 107 | 'content': { 108 | 'application/json': { 109 | 'schema': { 110 | 'type': 'string', 111 | 'title': 'Response Get Status V1 0 Status Get' 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | '/v2_0/status': { 120 | 'get': { 121 | 'tags': [ 122 | 'Status' 123 | ], 124 | 'summary': 'Get Status V2', 125 | 'operationId': 'get_status_v2_v2_0_status_get', 126 | 'responses': { 127 | '200': { 128 | 'description': 'Success!', 129 | 'content': { 130 | 'application/json': { 131 | 'schema': { 132 | 'type': 'string', 133 | 'title': 'Response Get Status V2 V2 0 Status Get' 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | }, 141 | '/latest/status': { 142 | 'get': { 143 | 'tags': [ 144 | 'Status' 145 | ], 146 | 'summary': 'Get Status V2', 147 | 'operationId': 'get_status_v2_latest_status_get', 148 | 'responses': { 149 | '200': { 150 | 'description': 'Success!', 151 | 'content': { 152 | 'application/json': { 153 | 'schema': { 154 | 'type': 'string', 155 | 'title': 'Response Get Status V2 Latest Status Get' 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | }, 165 | test_client.get('/openapi.json').json() 166 | ) 167 | self.assertDictEqual( 168 | { 169 | 'openapi': '3.1.0', 170 | 'info': { 171 | 'title': 'test - 1.0', 172 | 'version': 'v1_0' 173 | }, 174 | 'paths': { 175 | '/v1_0/deps': { 176 | 'get': { 177 | 'tags': [ 178 | 'Deps' 179 | ], 180 | 'summary': 'Get Deps', 181 | 'operationId': 'get_deps_v1_0_deps_get', 182 | 'responses': { 183 | '200': { 184 | 'description': 'Success!', 185 | 'content': { 186 | 'application/json': { 187 | 'schema': { 188 | 'type': 'string', 189 | 'title': 'Response Get Deps V1 0 Deps Get' 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | }, 197 | '/v1_0/status': { 198 | 'get': { 199 | 'tags': [ 200 | 'Status' 201 | ], 202 | 'summary': 'Get Status', 203 | 'operationId': 'get_status_v1_0_status_get', 204 | 'responses': { 205 | '200': { 206 | 'description': 'Success!', 207 | 'content': { 208 | 'application/json': { 209 | 'schema': { 210 | 'type': 'string', 211 | 'title': 'Response Get Status V1 0 Status Get' 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | } 219 | } 220 | }, 221 | test_client.get('/v1_0/openapi.json').json() 222 | ) 223 | self.assertDictEqual( 224 | { 225 | 'openapi': '3.1.0', 226 | 'info': { 227 | 'title': 'test - 2.0', 228 | 'version': 'v2_0' 229 | }, 230 | 'paths': { 231 | '/v2_0/status': { 232 | 'get': { 233 | 'tags': [ 234 | 'Status' 235 | ], 236 | 'summary': 'Get Status V2', 237 | 'operationId': 'get_status_v2_v2_0_status_get', 238 | 'responses': { 239 | '200': { 240 | 'description': 'Success!', 241 | 'content': { 242 | 'application/json': { 243 | 'schema': { 244 | 'type': 'string', 245 | 'title': 'Response Get Status V2 V2 0 Status Get' 246 | } 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | } 254 | }, 255 | test_client.get('/v2_0/openapi.json').json() 256 | ) 257 | self.assertDictEqual( 258 | { 259 | 'openapi': '3.1.0', 260 | 'info': { 261 | 'title': 'test - 2.0', 262 | 'version': 'latest' 263 | }, 264 | 'paths': { 265 | '/latest/status': { 266 | 'get': { 267 | 'tags': [ 268 | 'Status' 269 | ], 270 | 'summary': 'Get Status V2', 271 | 'operationId': 'get_status_v2_latest_status_get', 272 | 'responses': { 273 | '200': { 274 | 'description': 'Success!', 275 | 'content': { 276 | 'application/json': { 277 | 'schema': { 278 | 'type': 'string', 279 | 'title': 'Response Get Status V2 Latest Status Get' 280 | } 281 | } 282 | } 283 | } 284 | } 285 | } 286 | } 287 | } 288 | }, 289 | test_client.get('/latest/openapi.json').json() 290 | ) 291 | -------------------------------------------------------------------------------- /tests/test_websocket.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | import pydantic 3 | from fastapi import WebSocketDisconnect 4 | from fastapi.testclient import TestClient 5 | 6 | from unittest import TestCase 7 | from examples.websocket import app, versions 8 | 9 | 10 | class TestWebsocketExample(TestCase): 11 | 12 | def setUp(self) -> None: 13 | self.maxDiff = None 14 | 15 | def test_simple_example(self) -> None: 16 | test_client = TestClient(app) 17 | 18 | self.assertListEqual([(1, 0), (2, 0)], versions) 19 | 20 | # Make sure some pages don't exist 21 | self.assertEqual(404, test_client.get('/redoc').status_code) 22 | self.assertEqual(404, test_client.get('/v1/redoc').status_code) 23 | self.assertEqual(404, test_client.get('/v2/redoc').status_code) 24 | self.assertEqual(404, test_client.get('/latest/redoc').status_code) 25 | self.assertEqual(404, test_client.get('/chatterbox').status_code) 26 | self.assertEqual(404, test_client.get('/v1/versions').status_code) 27 | self.assertEqual(404, test_client.get('/v2/versions').status_code) 28 | self.assertEqual(404, test_client.get('/latest/versions').status_code) 29 | 30 | # versions route 31 | self.assertDictEqual( 32 | { 33 | 'versions': [ 34 | { 35 | 'version': '1', 36 | 'openapi_url': '/v1/api_schema.json', 37 | 'swagger_url': '/v1/swagger' 38 | }, 39 | { 40 | 'version': '2', 41 | 'openapi_url': '/v2/api_schema.json', 42 | 'swagger_url': '/v2/swagger', 43 | } 44 | ] 45 | }, 46 | test_client.get('/versions').json() 47 | ) 48 | 49 | # v1 50 | assert test_client.get('/v1/chatterbox').json() == 'v1' 51 | try: 52 | msg = None 53 | with test_client.websocket_connect('/v1/chatterbox') as websocket: 54 | websocket.send_text('ping') 55 | msg = websocket.receive_text() 56 | except WebSocketDisconnect: 57 | if msg is None: 58 | raise 59 | assert msg == 'ping' 60 | 61 | # v2 62 | assert test_client.get('/v2/chatterbox').json() == 'v2' 63 | try: 64 | with test_client.websocket_connect('/v2/chatterbox') as websocket: 65 | websocket.send_text('ping') 66 | msg = websocket.receive_text() 67 | except WebSocketDisconnect: 68 | if msg is None: 69 | raise 70 | assert msg == 'pong' 71 | 72 | # latest 73 | assert test_client.get('/v2/chatterbox').json() == 'v2' 74 | try: 75 | with test_client.websocket_connect('/v2/chatterbox') as websocket: 76 | websocket.send_text('ping') 77 | msg = websocket.receive_text() 78 | except WebSocketDisconnect: 79 | if msg is None: 80 | raise 81 | assert msg == 'pong' 82 | 83 | # docs 84 | self.assertEqual(200, test_client.get('/swagger').status_code) 85 | self.assertEqual(200, test_client.get('/v1/swagger').status_code) 86 | self.assertEqual(200, test_client.get('/v2/swagger').status_code) 87 | self.assertEqual(200, test_client.get('/latest/swagger').status_code) 88 | 89 | # openapi 90 | expected_response: Dict[str, Any] = { 91 | 'openapi': '3.1.0', 92 | 'info': { 93 | 'title': 'test', 94 | 'description': 'Websocket example of FastAPI Versionizer.', 95 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 96 | 'version': '0.1.0' 97 | }, 98 | 'paths': { 99 | '/v1/chatterbox': { 100 | 'get': { 101 | 'tags': [ 102 | 'Chatting' 103 | ], 104 | 'summary': 'Get Explaination', 105 | 'operationId': 'get_explaination_v1_chatterbox_get', 106 | 'responses': { 107 | '200': { 108 | 'description': 'Successful Response', 109 | 'content': { 110 | 'application/json': { 111 | 'schema': { 112 | 'type': 'string', 113 | 'title': 'Response Get Explaination V1 Chatterbox Get' 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | 'deprecated': True 120 | } 121 | }, 122 | '/v2/chatterbox': { 123 | 'get': { 124 | 'tags': [ 125 | 'Chatting' 126 | ], 127 | 'summary': 'Get Explaination V2', 128 | 'operationId': 'get_explaination_v2_v2_chatterbox_get', 129 | 'responses': { 130 | '200': { 131 | 'description': 'Successful Response', 132 | 'content': { 133 | 'application/json': { 134 | 'schema': { 135 | 'type': 'string', 136 | 'title': 'Response Get Explaination V2 V2 Chatterbox Get' 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | }, 144 | '/latest/chatterbox': { 145 | 'get': { 146 | 'tags': [ 147 | 'Chatting' 148 | ], 149 | 'summary': 'Get Explaination V2', 150 | 'operationId': 'get_explaination_v2_latest_chatterbox_get', 151 | 'responses': { 152 | '200': { 153 | 'description': 'Successful Response', 154 | 'content': { 155 | 'application/json': { 156 | 'schema': { 157 | 'type': 'string', 158 | 'title': 'Response Get Explaination V2 Latest Chatterbox Get' 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | }, 166 | '/versions': { 167 | 'get': { 168 | 'tags': [ 169 | 'Versions' 170 | ], 171 | 'summary': 'Get Versions', 172 | 'operationId': 'get_versions_versions_get', 173 | 'responses': { 174 | '200': { 175 | 'description': 'Successful Response', 176 | 'content': { 177 | 'application/json': { 178 | 'schema': { 179 | 'type': 'object', 180 | 'title': 'Response Get Versions Versions Get' 181 | } 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | if pydantic.__version__ >= '2.11.0': 191 | # added 'additionalProperties': True in the /versions API 192 | expected_response['paths']['/versions']['get']['responses']['200'][ 193 | 'content' 194 | ]['application/json']['schema']['additionalProperties'] = True 195 | self.assertDictEqual( 196 | expected_response, 197 | test_client.get('/api_schema.json').json() 198 | ) 199 | self.assertDictEqual( 200 | { 201 | 'openapi': '3.1.0', 202 | 'info': { 203 | 'title': 'test - v1', 204 | 'description': 'Websocket example of FastAPI Versionizer.', 205 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 206 | 'version': 'v1' 207 | }, 208 | 'paths': { 209 | '/v1/chatterbox': { 210 | 'get': { 211 | 'tags': [ 212 | 'Chatting' 213 | ], 214 | 'summary': 'Get Explaination', 215 | 'operationId': 'get_explaination_v1_chatterbox_get', 216 | 'responses': { 217 | '200': { 218 | 'description': 'Successful Response', 219 | 'content': { 220 | 'application/json': { 221 | 'schema': { 222 | 'type': 'string', 223 | 'title': 'Response Get Explaination V1 Chatterbox Get' 224 | } 225 | } 226 | } 227 | } 228 | }, 229 | 'deprecated': True 230 | } 231 | } 232 | } 233 | }, 234 | test_client.get('/v1/api_schema.json').json() 235 | ) 236 | self.assertDictEqual( 237 | { 238 | 'openapi': '3.1.0', 239 | 'info': { 240 | 'title': 'test - v2', 241 | 'description': 'Websocket example of FastAPI Versionizer.', 242 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 243 | 'version': 'v2' 244 | }, 245 | 'paths': { 246 | '/v2/chatterbox': { 247 | 'get': { 248 | 'tags': [ 249 | 'Chatting' 250 | ], 251 | 'summary': 'Get Explaination V2', 252 | 'operationId': 'get_explaination_v2_v2_chatterbox_get', 253 | 'responses': { 254 | '200': { 255 | 'description': 'Successful Response', 256 | 'content': { 257 | 'application/json': { 258 | 'schema': { 259 | 'type': 'string', 260 | 'title': 'Response Get Explaination V2 V2 Chatterbox Get' 261 | } 262 | } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | } 269 | }, 270 | test_client.get('/v2/api_schema.json').json() 271 | ) 272 | self.assertDictEqual( 273 | { 274 | 'openapi': '3.1.0', 275 | 'info': { 276 | 'title': 'test - v2', 277 | 'description': 'Websocket example of FastAPI Versionizer.', 278 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 279 | 'version': 'v2' 280 | }, 281 | 'paths': { 282 | '/latest/chatterbox': { 283 | 'get': { 284 | 'tags': [ 285 | 'Chatting' 286 | ], 287 | 'summary': 'Get Explaination V2', 288 | 'operationId': 'get_explaination_v2_latest_chatterbox_get', 289 | 'responses': { 290 | '200': { 291 | 'description': 'Successful Response', 292 | 'content': { 293 | 'application/json': { 294 | 'schema': { 295 | 'type': 'string', 296 | 'title': 'Response Get Explaination V2 Latest Chatterbox Get' 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | } 304 | } 305 | }, 306 | test_client.get('/latest/api_schema.json').json() 307 | ) 308 | -------------------------------------------------------------------------------- /fastapi_versionizer/versionizer.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from enum import Enum 3 | from fastapi import FastAPI, APIRouter 4 | from fastapi.openapi.docs import get_redoc_html 5 | from fastapi.openapi.docs import get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html 6 | import fastapi.openapi.utils 7 | from fastapi.responses import HTMLResponse, JSONResponse 8 | from fastapi.routing import APIRoute, APIWebSocketRoute 9 | from natsort import natsorted 10 | from typing import Any, Callable, Dict, List, Tuple, TypeVar, Union, cast, Set 11 | 12 | CallableT = TypeVar('CallableT', bound=Callable[..., Any]) 13 | 14 | 15 | def api_version( 16 | major: int, 17 | minor: int = 0, 18 | deprecate_in_major: Union[int, None] = None, 19 | deprecate_in_minor: int = 0, 20 | remove_in_major: Union[int, None] = None, 21 | remove_in_minor: int = 0 22 | ) -> Callable[[CallableT], CallableT]: 23 | """ 24 | Annotates a route as being available from the given version onward (until a 25 | new version of the route is assigned) 26 | """ 27 | 28 | def decorator(func: CallableT) -> CallableT: 29 | func._api_version = (major, minor) # type: ignore 30 | if deprecate_in_major is not None: 31 | func._deprecate_in_version = (deprecate_in_major, deprecate_in_minor) # type: ignore 32 | if remove_in_major is not None: 33 | func._remove_in_version = (remove_in_major, remove_in_minor) # type: ignore 34 | return func 35 | 36 | return decorator 37 | 38 | 39 | class Versionizer: 40 | 41 | def __init__( 42 | self, 43 | app: FastAPI, 44 | prefix_format: str = '/v{major}_{minor}', 45 | semantic_version_format: str = '{major}.{minor}', 46 | default_version: Tuple[int, int] = (1, 0), 47 | latest_prefix: Union[str, None] = None, 48 | include_main_docs: bool = True, 49 | include_main_openapi_route: bool = True, 50 | include_version_docs: bool = True, 51 | include_version_openapi_route: bool = True, 52 | include_versions_route: bool = False, 53 | sort_routes: bool = False, 54 | callback: Union[Callable[[APIRouter, Tuple[int, int], str], None], None] = None 55 | ): 56 | """ 57 | :param app: 58 | :param prefix_format: 59 | Used to build the version path prefix for routes. 60 | It should contain either "{major}" or "{minor}" or both. 61 | :param semantic_version_format: 62 | Used to build the semantic version, which is shown in docs. 63 | :param default_version: 64 | Default version used if a route is not annotated with @api_version. 65 | :param latest_prefix: 66 | If this is given, the routes in your latest version will be a given a separate prefix alias. 67 | For example, if latest_prefix='latest', latest version is 1, and you have routes: 68 | "GET /v1/a" and "POST /v1/b", then "GET /latest/a" and "POST /latest/b" will also be added. 69 | :param include_main_docs: 70 | If True, docs page(s) will be created at the root, with all versioned routes included 71 | :param include_main_openapi_route: 72 | If True, an openapi route will be created at the root, with all versioned routes included 73 | :param include_version_docs: 74 | If True, docs page(s) will be created for each version 75 | :param include_version_openapi_route: 76 | If True, an openapi route will be created for each version 77 | :param include_versions_route: 78 | If True, a "GET /versions" route will be added, which includes information about all API versions 79 | :param sort_routes: 80 | If True, all routes will be naturally sorted by path within each version. 81 | If you have included the main docs page, the routes are sorted within each version, and versions 82 | are sorted from earliest to latest. If you have added a "latest" alias, its routes will be listed last. 83 | :param callback: 84 | A function that is called each time a version router is created and all its routes have been added. 85 | It is called before the router has been added to the root FastAPI app. 86 | This function should not return anything and has the following parameters: 87 | - Version router 88 | - Version (in tuple form) 89 | - Version path prefix 90 | """ 91 | self._app = app 92 | self._original_app_routes = app.routes 93 | self._prefix_format = prefix_format 94 | self._semantic_version_format = semantic_version_format 95 | self._default_version = default_version 96 | self._latest_prefix = latest_prefix 97 | self._include_main_docs = include_main_docs 98 | self._include_main_openapi_route = include_main_openapi_route 99 | self._include_version_docs = include_version_docs 100 | self._include_version_openapi_route = include_version_openapi_route 101 | self._include_versions_route = include_versions_route 102 | self._sort_routes = sort_routes 103 | self._callback = callback 104 | 105 | self._strip_routes() 106 | 107 | def versionize(self) -> List[Tuple[int, int]]: 108 | """ 109 | Versions your FastAPI application, in place. 110 | 111 | :returns: list of all versions (each in tuple form) 112 | """ 113 | 114 | version, routes_by_key = None, None 115 | routes_by_version = self._get_routes_by_version() 116 | versions = list(routes_by_version.keys()) 117 | for version, routes_by_key in routes_by_version.items(): 118 | major, minor = version 119 | version_prefix = self._prefix_format.format(major=major, minor=minor) 120 | version_router = self._build_version_router( 121 | version=version, 122 | version_prefix=version_prefix, 123 | routes_by_key=routes_by_key 124 | ) 125 | if self._callback: 126 | self._callback(version_router, version, version_prefix) 127 | self._app.include_router(router=version_router) 128 | 129 | if self._latest_prefix is not None and routes_by_key and version: 130 | latest_router = self._build_version_router( 131 | version=version, 132 | version_prefix=self._latest_prefix, 133 | routes_by_key=routes_by_key 134 | ) 135 | if self._callback: 136 | self._callback(latest_router, version, self._latest_prefix) 137 | self._app.include_router(router=latest_router) 138 | 139 | if self._include_versions_route: 140 | self._add_versions_route(versions=versions) 141 | 142 | return versions 143 | 144 | def _build_api_url(self, version_prefix: str, path: str) -> str: 145 | root_path = (self._app.root_path or '').rstrip('/') 146 | return f'{root_path}{version_prefix}{path}' 147 | 148 | def _build_version_router( 149 | self, 150 | version: Tuple[int, int], 151 | version_prefix: str, 152 | routes_by_key: Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]] 153 | ) -> APIRouter: 154 | router = APIRouter( 155 | prefix=version_prefix 156 | ) 157 | routes_by_key = dict(natsorted(routes_by_key.items())) if self._sort_routes else routes_by_key 158 | for route in routes_by_key.values(): 159 | self._add_route_to_router(route=route, router=router, version=version) 160 | 161 | self._add_version_docs( 162 | router=router, 163 | version=version, 164 | version_prefix=version_prefix 165 | ) 166 | 167 | return router 168 | 169 | def _get_routes_by_version( 170 | self 171 | ) -> Dict[Tuple[int, int], Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]]]: 172 | routes_by_start_version: Dict[Tuple[int, int], List[Union[APIRoute, APIWebSocketRoute]]] = defaultdict(list) 173 | for route in self._original_app_routes: 174 | if isinstance(route, (APIRoute, APIWebSocketRoute)): 175 | version = getattr(route.endpoint, '_api_version', self._default_version) 176 | routes_by_start_version[version].append(route) 177 | 178 | routes_by_end_version: Dict[Tuple[int, int], List[Union[APIRoute, APIWebSocketRoute]]] = defaultdict(list) 179 | for route in self._original_app_routes: 180 | if isinstance(route, (APIRoute, APIWebSocketRoute)): 181 | version = getattr(route.endpoint, '_remove_in_version', None) 182 | if version: 183 | routes_by_end_version[version].append(route) 184 | 185 | versions = sorted(set(routes_by_start_version.keys())) 186 | routes_by_version: Dict[Tuple[int, int], Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]]] = {} 187 | curr_version_routes_by_key: Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]] = {} 188 | for version in versions: 189 | for route in routes_by_start_version[version]: 190 | route_keys = self._get_route_keys(route=route) 191 | curr_version_routes_by_key.update(route_keys) 192 | 193 | for route in routes_by_end_version[version]: 194 | route_keys = self._get_route_keys(route=route) 195 | for route_key, method_route in route_keys.items(): 196 | del curr_version_routes_by_key[route_key] 197 | 198 | routes_by_version[version] = dict(curr_version_routes_by_key) 199 | 200 | return routes_by_version 201 | 202 | @classmethod 203 | def _get_route_keys( 204 | cls, 205 | route: Union[APIRoute, APIWebSocketRoute] 206 | ) -> Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]]: 207 | routes_by_key: Dict[Tuple[str, str], Union[APIRoute, APIWebSocketRoute]] = {} 208 | if isinstance(route, APIRoute): 209 | for method in route.methods: 210 | routes_by_key[(route.path, method)] = route 211 | elif isinstance(route, APIWebSocketRoute): 212 | routes_by_key[(route.path, '')] = route 213 | 214 | return routes_by_key 215 | 216 | def _add_version_docs( 217 | self, 218 | router: APIRouter, 219 | version: Tuple[int, int], 220 | version_prefix: str 221 | ) -> None: 222 | version_str = f'v{self._semantic_version_format.format(major=version[0], minor=version[1])}' 223 | title = f'{self._app.title} - {version_str}' 224 | tags: Set[Union[str, Enum]] = set() 225 | versioned_tags: List[Dict[str, Any]] = [] 226 | 227 | if self._app.openapi_tags is not None: 228 | for route in router.routes: 229 | if isinstance(route, APIRoute): 230 | if isinstance(route.tags, list): 231 | tags.update(route.tags or ()) 232 | 233 | if tags: 234 | openapi_tags = self._app.openapi_tags or [] 235 | for openapi_tag in openapi_tags: 236 | if openapi_tag['name'] in tags: 237 | versioned_tags.append(openapi_tag) 238 | 239 | if self._include_version_openapi_route and self._app.openapi_url is not None: 240 | @router.get(self._app.openapi_url, include_in_schema=False) 241 | async def get_openapi() -> Any: 242 | openapi_params: Dict[str, Any] = { 243 | 'title': title, 244 | 'version': version_str, 245 | 'routes': router.routes, 246 | 'description': self._app.description, 247 | 'terms_of_service': self._app.terms_of_service, 248 | 'contact': self._app.contact, 249 | 'license_info': self._app.license_info, 250 | 'servers': self._app.servers, 251 | 'tags': versioned_tags, 252 | } 253 | 254 | if hasattr(self._app, 'summary'): 255 | # Available since OpenAPI 3.1.0, FastAPI 0.99.0 256 | openapi_params['summary'] = self._app.summary 257 | 258 | return fastapi.openapi.utils.get_openapi(**openapi_params) 259 | 260 | if self._include_version_docs and self._app.docs_url is not None and self._app.openapi_url is not None: 261 | openapi_url = self._build_api_url(version_prefix, self._app.openapi_url) 262 | oauth2_redirect_url = self._build_api_url( 263 | version_prefix, cast(str, self._app.swagger_ui_oauth2_redirect_url)) 264 | 265 | @router.get(self._app.docs_url, include_in_schema=False) 266 | async def get_docs() -> HTMLResponse: 267 | return get_swagger_ui_html( 268 | openapi_url=openapi_url, 269 | title=title, 270 | swagger_ui_parameters=self._app.swagger_ui_parameters, 271 | init_oauth=self._app.swagger_ui_init_oauth, 272 | oauth2_redirect_url=oauth2_redirect_url 273 | ) 274 | 275 | if self._app.swagger_ui_oauth2_redirect_url: 276 | @router.get(self._app.swagger_ui_oauth2_redirect_url, include_in_schema=False) 277 | async def get_oauth2_redirect() -> HTMLResponse: 278 | return get_swagger_ui_oauth2_redirect_html() 279 | 280 | if self._include_version_docs and self._app.redoc_url is not None and self._app.openapi_url is not None: 281 | @router.get(self._app.redoc_url, include_in_schema=False) 282 | async def get_redoc() -> HTMLResponse: 283 | openapi_url = self._build_api_url(version_prefix, cast(str, self._app.openapi_url)) 284 | return get_redoc_html( 285 | openapi_url=openapi_url, 286 | title=title 287 | ) 288 | 289 | def _add_versions_route(self, versions: List[Tuple[int, int]]) -> None: 290 | @self._app.get( 291 | '/versions', 292 | tags=['Versions'], 293 | response_class=JSONResponse 294 | ) 295 | def get_versions() -> Dict[str, Any]: 296 | version_models: List[Dict[str, Any]] = [] 297 | for (major, minor) in versions: 298 | version_prefix = self._prefix_format.format(major=major, minor=minor) 299 | version_str = self._semantic_version_format.format(major=major, minor=minor) 300 | 301 | version_model = { 302 | 'version': version_str, 303 | } 304 | 305 | if self._include_version_openapi_route and self._app.openapi_url is not None: 306 | version_model['openapi_url'] = self._build_api_url(version_prefix, self._app.openapi_url) 307 | 308 | if self._include_version_docs and self._app.docs_url is not None: 309 | version_model['swagger_url'] = self._build_api_url(version_prefix, self._app.docs_url) 310 | 311 | if self._include_version_docs and self._app.redoc_url is not None: 312 | version_model['redoc_url'] = self._build_api_url(version_prefix, self._app.redoc_url) 313 | 314 | version_models.append(version_model) 315 | 316 | return { 317 | 'versions': version_models 318 | } 319 | 320 | @staticmethod 321 | def _add_route_to_router( 322 | route: Union[APIRoute, APIWebSocketRoute], 323 | router: APIRouter, 324 | version: Tuple[int, int] 325 | ) -> None: 326 | kwargs = dict(route.__dict__) 327 | 328 | deprecated_in_version = getattr(route.endpoint, '_deprecate_in_version', None) 329 | if deprecated_in_version is not None: 330 | deprecated_in_major, deprecated_in_minor = deprecated_in_version 331 | if ( 332 | version[0] >= deprecated_in_major or 333 | (version[0] == deprecated_in_major and version[1] >= deprecated_in_minor) 334 | ): 335 | kwargs['deprecated'] = True 336 | 337 | for _ in range(10000): 338 | try: 339 | if isinstance(route, APIRoute): 340 | return router.add_api_route(**kwargs) 341 | elif isinstance(route, APIWebSocketRoute): 342 | return router.add_api_websocket_route(**kwargs) 343 | except TypeError as e: 344 | e_str = str(e) 345 | error_parts = e_str.split("'") 346 | if len(error_parts) < 2: 347 | raise RuntimeError(f'unknown type error: {e_str}') 348 | key_to_remove = error_parts[1] 349 | kwargs.pop(key_to_remove) 350 | 351 | raise RuntimeError('Failed to add route') 352 | 353 | def _strip_routes(self) -> None: 354 | paths_to_keep = [] 355 | if self._include_main_docs: 356 | paths_to_keep.extend([ 357 | self._app.docs_url, 358 | self._app.redoc_url, 359 | self._app.swagger_ui_oauth2_redirect_url 360 | ]) 361 | if self._include_main_openapi_route: 362 | paths_to_keep.append(self._app.openapi_url) 363 | 364 | self._app.router.routes = [ 365 | route for route in self._app.routes if 366 | getattr(route, 'path') in paths_to_keep 367 | ] 368 | -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from fastapi.testclient import TestClient 3 | 4 | from unittest import TestCase 5 | 6 | import pydantic 7 | from examples.simple import app, versions 8 | 9 | 10 | class TestSimpleExample(TestCase): 11 | 12 | def setUp(self) -> None: 13 | self.maxDiff = None 14 | 15 | def test_simple_example(self) -> None: 16 | test_client = TestClient(app) 17 | 18 | self.assertListEqual([(1, 0), (2, 0)], versions) 19 | 20 | # Make sure some pages don't exist 21 | self.assertEqual(404, test_client.get('/redoc').status_code) 22 | self.assertEqual(404, test_client.get('/v1/redoc').status_code) 23 | self.assertEqual(404, test_client.get('/v2/redoc').status_code) 24 | self.assertEqual(404, test_client.get('/latest/redoc').status_code) 25 | self.assertEqual(404, test_client.post('/users').status_code) 26 | self.assertEqual(404, test_client.post('/items').status_code) 27 | self.assertEqual(404, test_client.get('/users').status_code) 28 | self.assertEqual(404, test_client.get('/items').status_code) 29 | self.assertEqual(404, test_client.get('/users/1').status_code) 30 | self.assertEqual(404, test_client.get('/items/1').status_code) 31 | self.assertEqual(404, test_client.get('/v2/items/1').status_code) 32 | self.assertEqual(404, test_client.get('/v1/versions').status_code) 33 | self.assertEqual(404, test_client.get('/v2/versions').status_code) 34 | self.assertEqual(404, test_client.get('/latest/versions').status_code) 35 | 36 | # versions route 37 | self.assertDictEqual( 38 | { 39 | 'versions': [ 40 | { 41 | 'version': '1', 42 | 'openapi_url': '/v1/api_schema.json', 43 | 'swagger_url': '/v1/swagger' 44 | }, 45 | { 46 | 'version': '2', 47 | 'openapi_url': '/v2/api_schema.json', 48 | 'swagger_url': '/v2/swagger', 49 | } 50 | ] 51 | }, 52 | test_client.get('/versions').json() 53 | ) 54 | 55 | # v1 56 | self.assertDictEqual( 57 | {'id': 1, 'name': 'alex'}, 58 | test_client.post('/v1/users', json={'id': 1, 'name': 'alex'}).json() 59 | ) 60 | self.assertDictEqual( 61 | {'id': 1, 'name': 'laptop'}, 62 | test_client.post('/v1/items', json={'id': 1, 'name': 'laptop'}).json() 63 | ) 64 | self.assertListEqual( 65 | [{'id': 1, 'name': 'alex'}], 66 | test_client.get('/v1/users').json() 67 | ) 68 | self.assertListEqual( 69 | [{'id': 1, 'name': 'laptop'}], 70 | test_client.get('/v1/items').json() 71 | ) 72 | self.assertDictEqual( 73 | {'id': 1, 'name': 'alex'}, 74 | test_client.get('/v1/users/1').json() 75 | ) 76 | self.assertDictEqual( 77 | {'id': 1, 'name': 'laptop'}, 78 | test_client.get('/v1/items/1').json() 79 | ) 80 | 81 | # v2 82 | self.assertDictEqual( 83 | {'id': 2, 'name': 'zach', 'age': 30}, 84 | test_client.post('/v2/users', json={'id': 2, 'name': 'zach', 'age': 30}).json() 85 | ) 86 | self.assertDictEqual( 87 | {'id': 2, 'name': 'phone', 'cost': 10}, 88 | test_client.post('/v2/items', json={'id': 2, 'name': 'phone', 'cost': 10}).json() 89 | ) 90 | self.assertListEqual( 91 | [ 92 | {'id': 2, 'name': 'zach', 'age': 30} 93 | ], 94 | test_client.get('/v2/users').json() 95 | ) 96 | self.assertListEqual( 97 | [ 98 | {'id': 2, 'name': 'phone', 'cost': 10} 99 | ], 100 | test_client.get('/v2/items').json() 101 | ) 102 | self.assertDictEqual( 103 | {'id': 2, 'name': 'zach', 'age': 30}, 104 | test_client.get('/v2/users/2').json() 105 | ) 106 | 107 | # latest 108 | self.assertDictEqual( 109 | {'id': 3, 'name': 'dan', 'age': 65}, 110 | test_client.post('/latest/users', json={'id': 3, 'name': 'dan', 'age': 65}).json() 111 | ) 112 | self.assertDictEqual( 113 | {'id': 3, 'name': 'tv', 'cost': 1000}, 114 | test_client.post('/latest/items', json={'id': 3, 'name': 'tv', 'cost': 1000}).json() 115 | ) 116 | self.assertListEqual( 117 | [ 118 | {'id': 2, 'name': 'zach', 'age': 30}, 119 | {'id': 3, 'name': 'dan', 'age': 65} 120 | ], 121 | test_client.get('/latest/users').json() 122 | ) 123 | self.assertListEqual( 124 | [ 125 | {'id': 2, 'name': 'phone', 'cost': 10}, 126 | {'id': 3, 'name': 'tv', 'cost': 1000} 127 | ], 128 | test_client.get('/latest/items').json() 129 | ) 130 | self.assertDictEqual( 131 | {'id': 3, 'name': 'dan', 'age': 65}, 132 | test_client.get('/latest/users/3').json() 133 | ) 134 | 135 | # docs 136 | self.assertEqual(200, test_client.get('/swagger').status_code) 137 | self.assertEqual(200, test_client.get('/v1/swagger').status_code) 138 | self.assertEqual(200, test_client.get('/v2/swagger').status_code) 139 | self.assertEqual(200, test_client.get('/latest/swagger').status_code) 140 | expected_response: Dict[str, Any] = { 141 | 'openapi': '3.1.0', 142 | 'info': { 143 | 'title': 'test', 144 | 'description': 'Simple example of FastAPI Versionizer.', 145 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 146 | 'version': '0.1.0' 147 | }, 148 | 'paths': { 149 | '/v1/items': { 150 | 'get': { 151 | 'tags': [ 152 | 'Items' 153 | ], 154 | 'summary': 'Get Items', 155 | 'operationId': 'get_items_v1_items_get', 156 | 'responses': { 157 | '200': { 158 | 'description': 'Successful Response', 159 | 'content': { 160 | 'application/json': { 161 | 'schema': { 162 | 'items': { 163 | '$ref': '#/components/schemas/Item' 164 | }, 165 | 'type': 'array', 166 | 'title': 'Response Get Items V1 Items Get' 167 | } 168 | } 169 | } 170 | } 171 | }, 172 | 'deprecated': True 173 | }, 174 | 'post': { 175 | 'tags': [ 176 | 'Items' 177 | ], 178 | 'summary': 'Create Item', 179 | 'operationId': 'create_item_v1_items_post', 180 | 'requestBody': { 181 | 'content': { 182 | 'application/json': { 183 | 'schema': { 184 | '$ref': '#/components/schemas/Item' 185 | } 186 | } 187 | }, 188 | 'required': True 189 | }, 190 | 'responses': { 191 | '200': { 192 | 'description': 'Successful Response', 193 | 'content': { 194 | 'application/json': { 195 | 'schema': { 196 | '$ref': '#/components/schemas/Item' 197 | } 198 | } 199 | } 200 | }, 201 | '422': { 202 | 'description': 'Validation Error', 203 | 'content': { 204 | 'application/json': { 205 | 'schema': { 206 | '$ref': '#/components/schemas/HTTPValidationError' 207 | } 208 | } 209 | } 210 | } 211 | }, 212 | 'deprecated': True 213 | } 214 | }, 215 | '/v1/items/{item_id}': { 216 | 'get': { 217 | 'tags': [ 218 | 'Items' 219 | ], 220 | 'summary': 'Get Item', 221 | 'operationId': 'get_item_v1_items__item_id__get', 222 | 'deprecated': True, 223 | 'parameters': [ 224 | { 225 | 'name': 'item_id', 226 | 'in': 'path', 227 | 'required': True, 228 | 'schema': { 229 | 'type': 'integer', 230 | 'title': 'Item Id' 231 | } 232 | } 233 | ], 234 | 'responses': { 235 | '200': { 236 | 'description': 'Successful Response', 237 | 'content': { 238 | 'application/json': { 239 | 'schema': { 240 | '$ref': '#/components/schemas/Item' 241 | } 242 | } 243 | } 244 | }, 245 | '422': { 246 | 'description': 'Validation Error', 247 | 'content': { 248 | 'application/json': { 249 | 'schema': { 250 | '$ref': '#/components/schemas/HTTPValidationError' 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | }, 258 | '/v1/status': { 259 | 'get': { 260 | 'tags': [ 261 | 'Status' 262 | ], 263 | 'summary': 'Get Status', 264 | 'operationId': 'get_status_v1_status_get', 265 | 'responses': { 266 | '200': { 267 | 'description': 'Successful Response', 268 | 'content': { 269 | 'application/json': { 270 | 'schema': { 271 | 'type': 'string', 272 | 'title': 'Response Get Status V1 Status Get' 273 | } 274 | } 275 | } 276 | } 277 | } 278 | } 279 | }, 280 | '/v1/users': { 281 | 'get': { 282 | 'tags': [ 283 | 'Users' 284 | ], 285 | 'summary': 'Get Users', 286 | 'operationId': 'get_users_v1_users_get', 287 | 'responses': { 288 | '200': { 289 | 'description': 'Successful Response', 290 | 'content': { 291 | 'application/json': { 292 | 'schema': { 293 | 'items': { 294 | '$ref': '#/components/schemas/User' 295 | }, 296 | 'type': 'array', 297 | 'title': 'Response Get Users V1 Users Get' 298 | } 299 | } 300 | } 301 | } 302 | }, 303 | 'deprecated': True 304 | }, 305 | 'post': { 306 | 'tags': [ 307 | 'Users' 308 | ], 309 | 'summary': 'Create User', 310 | 'operationId': 'create_user_v1_users_post', 311 | 'requestBody': { 312 | 'content': { 313 | 'application/json': { 314 | 'schema': { 315 | '$ref': '#/components/schemas/User' 316 | } 317 | } 318 | }, 319 | 'required': True 320 | }, 321 | 'responses': { 322 | '200': { 323 | 'description': 'Successful Response', 324 | 'content': { 325 | 'application/json': { 326 | 'schema': { 327 | '$ref': '#/components/schemas/User' 328 | } 329 | } 330 | } 331 | }, 332 | '422': { 333 | 'description': 'Validation Error', 334 | 'content': { 335 | 'application/json': { 336 | 'schema': { 337 | '$ref': '#/components/schemas/HTTPValidationError' 338 | } 339 | } 340 | } 341 | } 342 | }, 343 | 'deprecated': True 344 | } 345 | }, 346 | '/v1/users/{user_id}': { 347 | 'get': { 348 | 'tags': [ 349 | 'Users' 350 | ], 351 | 'summary': 'Get User', 352 | 'operationId': 'get_user_v1_users__user_id__get', 353 | 'deprecated': True, 354 | 'parameters': [ 355 | { 356 | 'name': 'user_id', 357 | 'in': 'path', 358 | 'required': True, 359 | 'schema': { 360 | 'type': 'integer', 361 | 'title': 'User Id' 362 | } 363 | } 364 | ], 365 | 'responses': { 366 | '200': { 367 | 'description': 'Successful Response', 368 | 'content': { 369 | 'application/json': { 370 | 'schema': { 371 | '$ref': '#/components/schemas/User' 372 | } 373 | } 374 | } 375 | }, 376 | '422': { 377 | 'description': 'Validation Error', 378 | 'content': { 379 | 'application/json': { 380 | 'schema': { 381 | '$ref': '#/components/schemas/HTTPValidationError' 382 | } 383 | } 384 | } 385 | } 386 | } 387 | } 388 | }, 389 | '/v2/items': { 390 | 'get': { 391 | 'tags': [ 392 | 'Items' 393 | ], 394 | 'summary': 'Get Items V2', 395 | 'operationId': 'get_items_v2_v2_items_get', 396 | 'responses': { 397 | '200': { 398 | 'description': 'Successful Response', 399 | 'content': { 400 | 'application/json': { 401 | 'schema': { 402 | 'items': { 403 | '$ref': '#/components/schemas/ItemV2' 404 | }, 405 | 'type': 'array', 406 | 'title': 'Response Get Items V2 V2 Items Get' 407 | } 408 | } 409 | } 410 | } 411 | } 412 | }, 413 | 'post': { 414 | 'tags': [ 415 | 'Items' 416 | ], 417 | 'summary': 'Create Item V2', 418 | 'operationId': 'create_item_v2_v2_items_post', 419 | 'requestBody': { 420 | 'content': { 421 | 'application/json': { 422 | 'schema': { 423 | '$ref': '#/components/schemas/ItemV2' 424 | } 425 | } 426 | }, 427 | 'required': True 428 | }, 429 | 'responses': { 430 | '200': { 431 | 'description': 'Successful Response', 432 | 'content': { 433 | 'application/json': { 434 | 'schema': { 435 | '$ref': '#/components/schemas/ItemV2' 436 | } 437 | } 438 | } 439 | }, 440 | '422': { 441 | 'description': 'Validation Error', 442 | 'content': { 443 | 'application/json': { 444 | 'schema': { 445 | '$ref': '#/components/schemas/HTTPValidationError' 446 | } 447 | } 448 | } 449 | } 450 | } 451 | } 452 | }, 453 | '/v2/status': { 454 | 'get': { 455 | 'tags': [ 456 | 'Status' 457 | ], 458 | 'summary': 'Get Status', 459 | 'operationId': 'get_status_v2_status_get', 460 | 'responses': { 461 | '200': { 462 | 'description': 'Successful Response', 463 | 'content': { 464 | 'application/json': { 465 | 'schema': { 466 | 'type': 'string', 467 | 'title': 'Response Get Status V2 Status Get' 468 | } 469 | } 470 | } 471 | } 472 | } 473 | } 474 | }, 475 | '/v2/users': { 476 | 'get': { 477 | 'tags': [ 478 | 'Users' 479 | ], 480 | 'summary': 'Get Users V2', 481 | 'operationId': 'get_users_v2_v2_users_get', 482 | 'responses': { 483 | '200': { 484 | 'description': 'Successful Response', 485 | 'content': { 486 | 'application/json': { 487 | 'schema': { 488 | 'items': { 489 | '$ref': '#/components/schemas/UserV2' 490 | }, 491 | 'type': 'array', 492 | 'title': 'Response Get Users V2 V2 Users Get' 493 | } 494 | } 495 | } 496 | } 497 | } 498 | }, 499 | 'post': { 500 | 'tags': [ 501 | 'Users' 502 | ], 503 | 'summary': 'Create User V2', 504 | 'operationId': 'create_user_v2_v2_users_post', 505 | 'requestBody': { 506 | 'content': { 507 | 'application/json': { 508 | 'schema': { 509 | '$ref': '#/components/schemas/UserV2' 510 | } 511 | } 512 | }, 513 | 'required': True 514 | }, 515 | 'responses': { 516 | '200': { 517 | 'description': 'Successful Response', 518 | 'content': { 519 | 'application/json': { 520 | 'schema': { 521 | '$ref': '#/components/schemas/UserV2' 522 | } 523 | } 524 | } 525 | }, 526 | '422': { 527 | 'description': 'Validation Error', 528 | 'content': { 529 | 'application/json': { 530 | 'schema': { 531 | '$ref': '#/components/schemas/HTTPValidationError' 532 | } 533 | } 534 | } 535 | } 536 | } 537 | } 538 | }, 539 | '/v2/users/{user_id}': { 540 | 'get': { 541 | 'tags': [ 542 | 'Users' 543 | ], 544 | 'summary': 'Get User V2', 545 | 'operationId': 'get_user_v2_v2_users__user_id__get', 546 | 'parameters': [ 547 | { 548 | 'name': 'user_id', 549 | 'in': 'path', 550 | 'required': True, 551 | 'schema': { 552 | 'type': 'integer', 553 | 'title': 'User Id' 554 | } 555 | } 556 | ], 557 | 'responses': { 558 | '200': { 559 | 'description': 'Successful Response', 560 | 'content': { 561 | 'application/json': { 562 | 'schema': { 563 | '$ref': '#/components/schemas/UserV2' 564 | } 565 | } 566 | } 567 | }, 568 | '422': { 569 | 'description': 'Validation Error', 570 | 'content': { 571 | 'application/json': { 572 | 'schema': { 573 | '$ref': '#/components/schemas/HTTPValidationError' 574 | } 575 | } 576 | } 577 | } 578 | } 579 | } 580 | }, 581 | '/latest/items': { 582 | 'get': { 583 | 'tags': [ 584 | 'Items' 585 | ], 586 | 'summary': 'Get Items V2', 587 | 'operationId': 'get_items_v2_latest_items_get', 588 | 'responses': { 589 | '200': { 590 | 'description': 'Successful Response', 591 | 'content': { 592 | 'application/json': { 593 | 'schema': { 594 | 'items': { 595 | '$ref': '#/components/schemas/ItemV2' 596 | }, 597 | 'type': 'array', 598 | 'title': 'Response Get Items V2 Latest Items Get' 599 | } 600 | } 601 | } 602 | } 603 | } 604 | }, 605 | 'post': { 606 | 'tags': [ 607 | 'Items' 608 | ], 609 | 'summary': 'Create Item V2', 610 | 'operationId': 'create_item_v2_latest_items_post', 611 | 'requestBody': { 612 | 'content': { 613 | 'application/json': { 614 | 'schema': { 615 | '$ref': '#/components/schemas/ItemV2' 616 | } 617 | } 618 | }, 619 | 'required': True 620 | }, 621 | 'responses': { 622 | '200': { 623 | 'description': 'Successful Response', 624 | 'content': { 625 | 'application/json': { 626 | 'schema': { 627 | '$ref': '#/components/schemas/ItemV2' 628 | } 629 | } 630 | } 631 | }, 632 | '422': { 633 | 'description': 'Validation Error', 634 | 'content': { 635 | 'application/json': { 636 | 'schema': { 637 | '$ref': '#/components/schemas/HTTPValidationError' 638 | } 639 | } 640 | } 641 | } 642 | } 643 | } 644 | }, 645 | '/latest/status': { 646 | 'get': { 647 | 'tags': [ 648 | 'Status' 649 | ], 650 | 'summary': 'Get Status', 651 | 'operationId': 'get_status_latest_status_get', 652 | 'responses': { 653 | '200': { 654 | 'description': 'Successful Response', 655 | 'content': { 656 | 'application/json': { 657 | 'schema': { 658 | 'type': 'string', 659 | 'title': 'Response Get Status Latest Status Get' 660 | } 661 | } 662 | } 663 | } 664 | } 665 | } 666 | }, 667 | '/latest/users': { 668 | 'get': { 669 | 'tags': [ 670 | 'Users' 671 | ], 672 | 'summary': 'Get Users V2', 673 | 'operationId': 'get_users_v2_latest_users_get', 674 | 'responses': { 675 | '200': { 676 | 'description': 'Successful Response', 677 | 'content': { 678 | 'application/json': { 679 | 'schema': { 680 | 'items': { 681 | '$ref': '#/components/schemas/UserV2' 682 | }, 683 | 'type': 'array', 684 | 'title': 'Response Get Users V2 Latest Users Get' 685 | } 686 | } 687 | } 688 | } 689 | } 690 | }, 691 | 'post': { 692 | 'tags': [ 693 | 'Users' 694 | ], 695 | 'summary': 'Create User V2', 696 | 'operationId': 'create_user_v2_latest_users_post', 697 | 'requestBody': { 698 | 'content': { 699 | 'application/json': { 700 | 'schema': { 701 | '$ref': '#/components/schemas/UserV2' 702 | } 703 | } 704 | }, 705 | 'required': True 706 | }, 707 | 'responses': { 708 | '200': { 709 | 'description': 'Successful Response', 710 | 'content': { 711 | 'application/json': { 712 | 'schema': { 713 | '$ref': '#/components/schemas/UserV2' 714 | } 715 | } 716 | } 717 | }, 718 | '422': { 719 | 'description': 'Validation Error', 720 | 'content': { 721 | 'application/json': { 722 | 'schema': { 723 | '$ref': '#/components/schemas/HTTPValidationError' 724 | } 725 | } 726 | } 727 | } 728 | } 729 | } 730 | }, 731 | '/latest/users/{user_id}': { 732 | 'get': { 733 | 'tags': [ 734 | 'Users' 735 | ], 736 | 'summary': 'Get User V2', 737 | 'operationId': 'get_user_v2_latest_users__user_id__get', 738 | 'parameters': [ 739 | { 740 | 'name': 'user_id', 741 | 'in': 'path', 742 | 'required': True, 743 | 'schema': { 744 | 'type': 'integer', 745 | 'title': 'User Id' 746 | } 747 | } 748 | ], 749 | 'responses': { 750 | '200': { 751 | 'description': 'Successful Response', 752 | 'content': { 753 | 'application/json': { 754 | 'schema': { 755 | '$ref': '#/components/schemas/UserV2' 756 | } 757 | } 758 | } 759 | }, 760 | '422': { 761 | 'description': 'Validation Error', 762 | 'content': { 763 | 'application/json': { 764 | 'schema': { 765 | '$ref': '#/components/schemas/HTTPValidationError' 766 | } 767 | } 768 | } 769 | } 770 | } 771 | } 772 | }, 773 | '/versions': { 774 | 'get': { 775 | 'tags': [ 776 | 'Versions' 777 | ], 778 | 'summary': 'Get Versions', 779 | 'operationId': 'get_versions_versions_get', 780 | 'responses': { 781 | '200': { 782 | 'description': 'Successful Response', 783 | 'content': { 784 | 'application/json': { 785 | 'schema': { 786 | 'type': 'object', 787 | 'title': 'Response Get Versions Versions Get' 788 | } 789 | } 790 | } 791 | } 792 | } 793 | } 794 | } 795 | }, 796 | 'components': { 797 | 'schemas': { 798 | 'HTTPValidationError': { 799 | 'properties': { 800 | 'detail': { 801 | 'items': { 802 | '$ref': '#/components/schemas/ValidationError' 803 | }, 804 | 'type': 'array', 805 | 'title': 'Detail' 806 | } 807 | }, 808 | 'type': 'object', 809 | 'title': 'HTTPValidationError' 810 | }, 811 | 'Item': { 812 | 'properties': { 813 | 'id': { 814 | 'type': 'integer', 815 | 'title': 'Id' 816 | }, 817 | 'name': { 818 | 'type': 'string', 819 | 'title': 'Name' 820 | } 821 | }, 822 | 'type': 'object', 823 | 'required': [ 824 | 'id', 825 | 'name' 826 | ], 827 | 'title': 'Item' 828 | }, 829 | 'ItemV2': { 830 | 'properties': { 831 | 'id': { 832 | 'type': 'integer', 833 | 'title': 'Id' 834 | }, 835 | 'name': { 836 | 'type': 'string', 837 | 'title': 'Name' 838 | }, 839 | 'cost': { 840 | 'type': 'integer', 841 | 'title': 'Cost' 842 | } 843 | }, 844 | 'type': 'object', 845 | 'required': [ 846 | 'id', 847 | 'name', 848 | 'cost' 849 | ], 850 | 'title': 'ItemV2' 851 | }, 852 | 'User': { 853 | 'properties': { 854 | 'id': { 855 | 'type': 'integer', 856 | 'title': 'Id' 857 | }, 858 | 'name': { 859 | 'type': 'string', 860 | 'title': 'Name' 861 | } 862 | }, 863 | 'type': 'object', 864 | 'required': [ 865 | 'id', 866 | 'name' 867 | ], 868 | 'title': 'User' 869 | }, 870 | 'UserV2': { 871 | 'properties': { 872 | 'id': { 873 | 'type': 'integer', 874 | 'title': 'Id' 875 | }, 876 | 'name': { 877 | 'type': 'string', 878 | 'title': 'Name' 879 | }, 880 | 'age': { 881 | 'type': 'integer', 882 | 'title': 'Age' 883 | } 884 | }, 885 | 'type': 'object', 886 | 'required': [ 887 | 'id', 888 | 'name', 889 | 'age' 890 | ], 891 | 'title': 'UserV2' 892 | }, 893 | 'ValidationError': { 894 | 'properties': { 895 | 'loc': { 896 | 'items': { 897 | 'anyOf': [ 898 | { 899 | 'type': 'string' 900 | }, 901 | { 902 | 'type': 'integer' 903 | } 904 | ] 905 | }, 906 | 'type': 'array', 907 | 'title': 'Location' 908 | }, 909 | 'msg': { 910 | 'type': 'string', 911 | 'title': 'Message' 912 | }, 913 | 'type': { 914 | 'type': 'string', 915 | 'title': 'Error Type' 916 | } 917 | }, 918 | 'type': 'object', 919 | 'required': [ 920 | 'loc', 921 | 'msg', 922 | 'type' 923 | ], 924 | 'title': 'ValidationError' 925 | } 926 | } 927 | } 928 | } 929 | if pydantic.__version__ >= '2.11.0': 930 | # added 'additionalProperties': True in the /versions API 931 | expected_response['paths']['/versions']['get']['responses']['200'][ 932 | 'content' 933 | ]['application/json']['schema']['additionalProperties'] = True 934 | # openapi 935 | self.assertDictEqual( 936 | expected_response, 937 | test_client.get('/api_schema.json').json() 938 | ) 939 | self.assertDictEqual( 940 | { 941 | 'openapi': '3.1.0', 942 | 'info': { 943 | 'title': 'test - v1', 944 | 'description': 'Simple example of FastAPI Versionizer.', 945 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 946 | 'version': 'v1' 947 | }, 948 | 'paths': { 949 | '/v1/items': { 950 | 'get': { 951 | 'tags': [ 952 | 'Items' 953 | ], 954 | 'summary': 'Get Items', 955 | 'operationId': 'get_items_v1_items_get', 956 | 'responses': { 957 | '200': { 958 | 'description': 'Successful Response', 959 | 'content': { 960 | 'application/json': { 961 | 'schema': { 962 | 'items': { 963 | '$ref': '#/components/schemas/Item' 964 | }, 965 | 'type': 'array', 966 | 'title': 'Response Get Items V1 Items Get' 967 | } 968 | } 969 | } 970 | } 971 | }, 972 | 'deprecated': True 973 | }, 974 | 'post': { 975 | 'tags': [ 976 | 'Items' 977 | ], 978 | 'summary': 'Create Item', 979 | 'operationId': 'create_item_v1_items_post', 980 | 'requestBody': { 981 | 'content': { 982 | 'application/json': { 983 | 'schema': { 984 | '$ref': '#/components/schemas/Item' 985 | } 986 | } 987 | }, 988 | 'required': True 989 | }, 990 | 'responses': { 991 | '200': { 992 | 'description': 'Successful Response', 993 | 'content': { 994 | 'application/json': { 995 | 'schema': { 996 | '$ref': '#/components/schemas/Item' 997 | } 998 | } 999 | } 1000 | }, 1001 | '422': { 1002 | 'description': 'Validation Error', 1003 | 'content': { 1004 | 'application/json': { 1005 | 'schema': { 1006 | '$ref': '#/components/schemas/HTTPValidationError' 1007 | } 1008 | } 1009 | } 1010 | } 1011 | }, 1012 | 'deprecated': True 1013 | } 1014 | }, 1015 | '/v1/items/{item_id}': { 1016 | 'get': { 1017 | 'tags': [ 1018 | 'Items' 1019 | ], 1020 | 'summary': 'Get Item', 1021 | 'operationId': 'get_item_v1_items__item_id__get', 1022 | 'deprecated': True, 1023 | 'parameters': [ 1024 | { 1025 | 'name': 'item_id', 1026 | 'in': 'path', 1027 | 'required': True, 1028 | 'schema': { 1029 | 'type': 'integer', 1030 | 'title': 'Item Id' 1031 | } 1032 | } 1033 | ], 1034 | 'responses': { 1035 | '200': { 1036 | 'description': 'Successful Response', 1037 | 'content': { 1038 | 'application/json': { 1039 | 'schema': { 1040 | '$ref': '#/components/schemas/Item' 1041 | } 1042 | } 1043 | } 1044 | }, 1045 | '422': { 1046 | 'description': 'Validation Error', 1047 | 'content': { 1048 | 'application/json': { 1049 | 'schema': { 1050 | '$ref': '#/components/schemas/HTTPValidationError' 1051 | } 1052 | } 1053 | } 1054 | } 1055 | } 1056 | } 1057 | }, 1058 | '/v1/status': { 1059 | 'get': { 1060 | 'tags': [ 1061 | 'Status' 1062 | ], 1063 | 'summary': 'Get Status', 1064 | 'operationId': 'get_status_v1_status_get', 1065 | 'responses': { 1066 | '200': { 1067 | 'description': 'Successful Response', 1068 | 'content': { 1069 | 'application/json': { 1070 | 'schema': { 1071 | 'type': 'string', 1072 | 'title': 'Response Get Status V1 Status Get' 1073 | } 1074 | } 1075 | } 1076 | } 1077 | } 1078 | } 1079 | }, 1080 | '/v1/users': { 1081 | 'get': { 1082 | 'tags': [ 1083 | 'Users' 1084 | ], 1085 | 'summary': 'Get Users', 1086 | 'operationId': 'get_users_v1_users_get', 1087 | 'responses': { 1088 | '200': { 1089 | 'description': 'Successful Response', 1090 | 'content': { 1091 | 'application/json': { 1092 | 'schema': { 1093 | 'items': { 1094 | '$ref': '#/components/schemas/User' 1095 | }, 1096 | 'type': 'array', 1097 | 'title': 'Response Get Users V1 Users Get' 1098 | } 1099 | } 1100 | } 1101 | } 1102 | }, 1103 | 'deprecated': True 1104 | }, 1105 | 'post': { 1106 | 'tags': [ 1107 | 'Users' 1108 | ], 1109 | 'summary': 'Create User', 1110 | 'operationId': 'create_user_v1_users_post', 1111 | 'requestBody': { 1112 | 'content': { 1113 | 'application/json': { 1114 | 'schema': { 1115 | '$ref': '#/components/schemas/User' 1116 | } 1117 | } 1118 | }, 1119 | 'required': True 1120 | }, 1121 | 'responses': { 1122 | '200': { 1123 | 'description': 'Successful Response', 1124 | 'content': { 1125 | 'application/json': { 1126 | 'schema': { 1127 | '$ref': '#/components/schemas/User' 1128 | } 1129 | } 1130 | } 1131 | }, 1132 | '422': { 1133 | 'description': 'Validation Error', 1134 | 'content': { 1135 | 'application/json': { 1136 | 'schema': { 1137 | '$ref': '#/components/schemas/HTTPValidationError' 1138 | } 1139 | } 1140 | } 1141 | } 1142 | }, 1143 | 'deprecated': True 1144 | } 1145 | }, 1146 | '/v1/users/{user_id}': { 1147 | 'get': { 1148 | 'tags': [ 1149 | 'Users' 1150 | ], 1151 | 'summary': 'Get User', 1152 | 'operationId': 'get_user_v1_users__user_id__get', 1153 | 'deprecated': True, 1154 | 'parameters': [ 1155 | { 1156 | 'name': 'user_id', 1157 | 'in': 'path', 1158 | 'required': True, 1159 | 'schema': { 1160 | 'type': 'integer', 1161 | 'title': 'User Id' 1162 | } 1163 | } 1164 | ], 1165 | 'responses': { 1166 | '200': { 1167 | 'description': 'Successful Response', 1168 | 'content': { 1169 | 'application/json': { 1170 | 'schema': { 1171 | '$ref': '#/components/schemas/User' 1172 | } 1173 | } 1174 | } 1175 | }, 1176 | '422': { 1177 | 'description': 'Validation Error', 1178 | 'content': { 1179 | 'application/json': { 1180 | 'schema': { 1181 | '$ref': '#/components/schemas/HTTPValidationError' 1182 | } 1183 | } 1184 | } 1185 | } 1186 | } 1187 | } 1188 | } 1189 | }, 1190 | 'components': { 1191 | 'schemas': { 1192 | 'HTTPValidationError': { 1193 | 'properties': { 1194 | 'detail': { 1195 | 'items': { 1196 | '$ref': '#/components/schemas/ValidationError' 1197 | }, 1198 | 'type': 'array', 1199 | 'title': 'Detail' 1200 | } 1201 | }, 1202 | 'type': 'object', 1203 | 'title': 'HTTPValidationError' 1204 | }, 1205 | 'Item': { 1206 | 'properties': { 1207 | 'id': { 1208 | 'type': 'integer', 1209 | 'title': 'Id' 1210 | }, 1211 | 'name': { 1212 | 'type': 'string', 1213 | 'title': 'Name' 1214 | } 1215 | }, 1216 | 'type': 'object', 1217 | 'required': [ 1218 | 'id', 1219 | 'name' 1220 | ], 1221 | 'title': 'Item' 1222 | }, 1223 | 'User': { 1224 | 'properties': { 1225 | 'id': { 1226 | 'type': 'integer', 1227 | 'title': 'Id' 1228 | }, 1229 | 'name': { 1230 | 'type': 'string', 1231 | 'title': 'Name' 1232 | } 1233 | }, 1234 | 'type': 'object', 1235 | 'required': [ 1236 | 'id', 1237 | 'name' 1238 | ], 1239 | 'title': 'User' 1240 | }, 1241 | 'ValidationError': { 1242 | 'properties': { 1243 | 'loc': { 1244 | 'items': { 1245 | 'anyOf': [ 1246 | { 1247 | 'type': 'string' 1248 | }, 1249 | { 1250 | 'type': 'integer' 1251 | } 1252 | ] 1253 | }, 1254 | 'type': 'array', 1255 | 'title': 'Location' 1256 | }, 1257 | 'msg': { 1258 | 'type': 'string', 1259 | 'title': 'Message' 1260 | }, 1261 | 'type': { 1262 | 'type': 'string', 1263 | 'title': 'Error Type' 1264 | } 1265 | }, 1266 | 'type': 'object', 1267 | 'required': [ 1268 | 'loc', 1269 | 'msg', 1270 | 'type' 1271 | ], 1272 | 'title': 'ValidationError' 1273 | } 1274 | } 1275 | } 1276 | }, 1277 | test_client.get('/v1/api_schema.json').json() 1278 | ) 1279 | self.assertDictEqual( 1280 | { 1281 | 'openapi': '3.1.0', 1282 | 'info': { 1283 | 'title': 'test - v2', 1284 | 'description': 'Simple example of FastAPI Versionizer.', 1285 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 1286 | 'version': 'v2' 1287 | }, 1288 | 'paths': { 1289 | '/v2/items': { 1290 | 'get': { 1291 | 'tags': [ 1292 | 'Items' 1293 | ], 1294 | 'summary': 'Get Items V2', 1295 | 'operationId': 'get_items_v2_v2_items_get', 1296 | 'responses': { 1297 | '200': { 1298 | 'description': 'Successful Response', 1299 | 'content': { 1300 | 'application/json': { 1301 | 'schema': { 1302 | 'items': { 1303 | '$ref': '#/components/schemas/ItemV2' 1304 | }, 1305 | 'type': 'array', 1306 | 'title': 'Response Get Items V2 V2 Items Get' 1307 | } 1308 | } 1309 | } 1310 | } 1311 | } 1312 | }, 1313 | 'post': { 1314 | 'tags': [ 1315 | 'Items' 1316 | ], 1317 | 'summary': 'Create Item V2', 1318 | 'operationId': 'create_item_v2_v2_items_post', 1319 | 'requestBody': { 1320 | 'content': { 1321 | 'application/json': { 1322 | 'schema': { 1323 | '$ref': '#/components/schemas/ItemV2' 1324 | } 1325 | } 1326 | }, 1327 | 'required': True 1328 | }, 1329 | 'responses': { 1330 | '200': { 1331 | 'description': 'Successful Response', 1332 | 'content': { 1333 | 'application/json': { 1334 | 'schema': { 1335 | '$ref': '#/components/schemas/ItemV2' 1336 | } 1337 | } 1338 | } 1339 | }, 1340 | '422': { 1341 | 'description': 'Validation Error', 1342 | 'content': { 1343 | 'application/json': { 1344 | 'schema': { 1345 | '$ref': '#/components/schemas/HTTPValidationError' 1346 | } 1347 | } 1348 | } 1349 | } 1350 | } 1351 | } 1352 | }, 1353 | '/v2/status': { 1354 | 'get': { 1355 | 'tags': [ 1356 | 'Status' 1357 | ], 1358 | 'summary': 'Get Status', 1359 | 'operationId': 'get_status_v2_status_get', 1360 | 'responses': { 1361 | '200': { 1362 | 'description': 'Successful Response', 1363 | 'content': { 1364 | 'application/json': { 1365 | 'schema': { 1366 | 'type': 'string', 1367 | 'title': 'Response Get Status V2 Status Get' 1368 | } 1369 | } 1370 | } 1371 | } 1372 | } 1373 | } 1374 | }, 1375 | '/v2/users': { 1376 | 'get': { 1377 | 'tags': [ 1378 | 'Users' 1379 | ], 1380 | 'summary': 'Get Users V2', 1381 | 'operationId': 'get_users_v2_v2_users_get', 1382 | 'responses': { 1383 | '200': { 1384 | 'description': 'Successful Response', 1385 | 'content': { 1386 | 'application/json': { 1387 | 'schema': { 1388 | 'items': { 1389 | '$ref': '#/components/schemas/UserV2' 1390 | }, 1391 | 'type': 'array', 1392 | 'title': 'Response Get Users V2 V2 Users Get' 1393 | } 1394 | } 1395 | } 1396 | } 1397 | } 1398 | }, 1399 | 'post': { 1400 | 'tags': [ 1401 | 'Users' 1402 | ], 1403 | 'summary': 'Create User V2', 1404 | 'operationId': 'create_user_v2_v2_users_post', 1405 | 'requestBody': { 1406 | 'content': { 1407 | 'application/json': { 1408 | 'schema': { 1409 | '$ref': '#/components/schemas/UserV2' 1410 | } 1411 | } 1412 | }, 1413 | 'required': True 1414 | }, 1415 | 'responses': { 1416 | '200': { 1417 | 'description': 'Successful Response', 1418 | 'content': { 1419 | 'application/json': { 1420 | 'schema': { 1421 | '$ref': '#/components/schemas/UserV2' 1422 | } 1423 | } 1424 | } 1425 | }, 1426 | '422': { 1427 | 'description': 'Validation Error', 1428 | 'content': { 1429 | 'application/json': { 1430 | 'schema': { 1431 | '$ref': '#/components/schemas/HTTPValidationError' 1432 | } 1433 | } 1434 | } 1435 | } 1436 | } 1437 | } 1438 | }, 1439 | '/v2/users/{user_id}': { 1440 | 'get': { 1441 | 'tags': [ 1442 | 'Users' 1443 | ], 1444 | 'summary': 'Get User V2', 1445 | 'operationId': 'get_user_v2_v2_users__user_id__get', 1446 | 'parameters': [ 1447 | { 1448 | 'name': 'user_id', 1449 | 'in': 'path', 1450 | 'required': True, 1451 | 'schema': { 1452 | 'type': 'integer', 1453 | 'title': 'User Id' 1454 | } 1455 | } 1456 | ], 1457 | 'responses': { 1458 | '200': { 1459 | 'description': 'Successful Response', 1460 | 'content': { 1461 | 'application/json': { 1462 | 'schema': { 1463 | '$ref': '#/components/schemas/UserV2' 1464 | } 1465 | } 1466 | } 1467 | }, 1468 | '422': { 1469 | 'description': 'Validation Error', 1470 | 'content': { 1471 | 'application/json': { 1472 | 'schema': { 1473 | '$ref': '#/components/schemas/HTTPValidationError' 1474 | } 1475 | } 1476 | } 1477 | } 1478 | } 1479 | } 1480 | } 1481 | }, 1482 | 'components': { 1483 | 'schemas': { 1484 | 'HTTPValidationError': { 1485 | 'properties': { 1486 | 'detail': { 1487 | 'items': { 1488 | '$ref': '#/components/schemas/ValidationError' 1489 | }, 1490 | 'type': 'array', 1491 | 'title': 'Detail' 1492 | } 1493 | }, 1494 | 'type': 'object', 1495 | 'title': 'HTTPValidationError' 1496 | }, 1497 | 'ItemV2': { 1498 | 'properties': { 1499 | 'id': { 1500 | 'type': 'integer', 1501 | 'title': 'Id' 1502 | }, 1503 | 'name': { 1504 | 'type': 'string', 1505 | 'title': 'Name' 1506 | }, 1507 | 'cost': { 1508 | 'type': 'integer', 1509 | 'title': 'Cost' 1510 | } 1511 | }, 1512 | 'type': 'object', 1513 | 'required': [ 1514 | 'id', 1515 | 'name', 1516 | 'cost' 1517 | ], 1518 | 'title': 'ItemV2' 1519 | }, 1520 | 'UserV2': { 1521 | 'properties': { 1522 | 'id': { 1523 | 'type': 'integer', 1524 | 'title': 'Id' 1525 | }, 1526 | 'name': { 1527 | 'type': 'string', 1528 | 'title': 'Name' 1529 | }, 1530 | 'age': { 1531 | 'type': 'integer', 1532 | 'title': 'Age' 1533 | } 1534 | }, 1535 | 'type': 'object', 1536 | 'required': [ 1537 | 'id', 1538 | 'name', 1539 | 'age' 1540 | ], 1541 | 'title': 'UserV2' 1542 | }, 1543 | 'ValidationError': { 1544 | 'properties': { 1545 | 'loc': { 1546 | 'items': { 1547 | 'anyOf': [ 1548 | { 1549 | 'type': 'string' 1550 | }, 1551 | { 1552 | 'type': 'integer' 1553 | } 1554 | ] 1555 | }, 1556 | 'type': 'array', 1557 | 'title': 'Location' 1558 | }, 1559 | 'msg': { 1560 | 'type': 'string', 1561 | 'title': 'Message' 1562 | }, 1563 | 'type': { 1564 | 'type': 'string', 1565 | 'title': 'Error Type' 1566 | } 1567 | }, 1568 | 'type': 'object', 1569 | 'required': [ 1570 | 'loc', 1571 | 'msg', 1572 | 'type' 1573 | ], 1574 | 'title': 'ValidationError' 1575 | } 1576 | } 1577 | } 1578 | }, 1579 | test_client.get('/v2/api_schema.json').json() 1580 | ) 1581 | self.assertDictEqual( 1582 | { 1583 | 'openapi': '3.1.0', 1584 | 'info': { 1585 | 'title': 'test - v2', 1586 | 'description': 'Simple example of FastAPI Versionizer.', 1587 | 'termsOfService': 'https://github.com/alexschimpf/fastapi-versionizer', 1588 | 'version': 'v2' 1589 | }, 1590 | 'paths': { 1591 | '/latest/items': { 1592 | 'get': { 1593 | 'tags': [ 1594 | 'Items' 1595 | ], 1596 | 'summary': 'Get Items V2', 1597 | 'operationId': 'get_items_v2_latest_items_get', 1598 | 'responses': { 1599 | '200': { 1600 | 'description': 'Successful Response', 1601 | 'content': { 1602 | 'application/json': { 1603 | 'schema': { 1604 | 'items': { 1605 | '$ref': '#/components/schemas/ItemV2' 1606 | }, 1607 | 'type': 'array', 1608 | 'title': 'Response Get Items V2 Latest Items Get' 1609 | } 1610 | } 1611 | } 1612 | } 1613 | } 1614 | }, 1615 | 'post': { 1616 | 'tags': [ 1617 | 'Items' 1618 | ], 1619 | 'summary': 'Create Item V2', 1620 | 'operationId': 'create_item_v2_latest_items_post', 1621 | 'requestBody': { 1622 | 'content': { 1623 | 'application/json': { 1624 | 'schema': { 1625 | '$ref': '#/components/schemas/ItemV2' 1626 | } 1627 | } 1628 | }, 1629 | 'required': True 1630 | }, 1631 | 'responses': { 1632 | '200': { 1633 | 'description': 'Successful Response', 1634 | 'content': { 1635 | 'application/json': { 1636 | 'schema': { 1637 | '$ref': '#/components/schemas/ItemV2' 1638 | } 1639 | } 1640 | } 1641 | }, 1642 | '422': { 1643 | 'description': 'Validation Error', 1644 | 'content': { 1645 | 'application/json': { 1646 | 'schema': { 1647 | '$ref': '#/components/schemas/HTTPValidationError' 1648 | } 1649 | } 1650 | } 1651 | } 1652 | } 1653 | } 1654 | }, 1655 | '/latest/status': { 1656 | 'get': { 1657 | 'tags': [ 1658 | 'Status' 1659 | ], 1660 | 'summary': 'Get Status', 1661 | 'operationId': 'get_status_latest_status_get', 1662 | 'responses': { 1663 | '200': { 1664 | 'description': 'Successful Response', 1665 | 'content': { 1666 | 'application/json': { 1667 | 'schema': { 1668 | 'type': 'string', 1669 | 'title': 'Response Get Status Latest Status Get' 1670 | } 1671 | } 1672 | } 1673 | } 1674 | } 1675 | } 1676 | }, 1677 | '/latest/users': { 1678 | 'get': { 1679 | 'tags': [ 1680 | 'Users' 1681 | ], 1682 | 'summary': 'Get Users V2', 1683 | 'operationId': 'get_users_v2_latest_users_get', 1684 | 'responses': { 1685 | '200': { 1686 | 'description': 'Successful Response', 1687 | 'content': { 1688 | 'application/json': { 1689 | 'schema': { 1690 | 'items': { 1691 | '$ref': '#/components/schemas/UserV2' 1692 | }, 1693 | 'type': 'array', 1694 | 'title': 'Response Get Users V2 Latest Users Get' 1695 | } 1696 | } 1697 | } 1698 | } 1699 | } 1700 | }, 1701 | 'post': { 1702 | 'tags': [ 1703 | 'Users' 1704 | ], 1705 | 'summary': 'Create User V2', 1706 | 'operationId': 'create_user_v2_latest_users_post', 1707 | 'requestBody': { 1708 | 'content': { 1709 | 'application/json': { 1710 | 'schema': { 1711 | '$ref': '#/components/schemas/UserV2' 1712 | } 1713 | } 1714 | }, 1715 | 'required': True 1716 | }, 1717 | 'responses': { 1718 | '200': { 1719 | 'description': 'Successful Response', 1720 | 'content': { 1721 | 'application/json': { 1722 | 'schema': { 1723 | '$ref': '#/components/schemas/UserV2' 1724 | } 1725 | } 1726 | } 1727 | }, 1728 | '422': { 1729 | 'description': 'Validation Error', 1730 | 'content': { 1731 | 'application/json': { 1732 | 'schema': { 1733 | '$ref': '#/components/schemas/HTTPValidationError' 1734 | } 1735 | } 1736 | } 1737 | } 1738 | } 1739 | } 1740 | }, 1741 | '/latest/users/{user_id}': { 1742 | 'get': { 1743 | 'tags': [ 1744 | 'Users' 1745 | ], 1746 | 'summary': 'Get User V2', 1747 | 'operationId': 'get_user_v2_latest_users__user_id__get', 1748 | 'parameters': [ 1749 | { 1750 | 'name': 'user_id', 1751 | 'in': 'path', 1752 | 'required': True, 1753 | 'schema': { 1754 | 'type': 'integer', 1755 | 'title': 'User Id' 1756 | } 1757 | } 1758 | ], 1759 | 'responses': { 1760 | '200': { 1761 | 'description': 'Successful Response', 1762 | 'content': { 1763 | 'application/json': { 1764 | 'schema': { 1765 | '$ref': '#/components/schemas/UserV2' 1766 | } 1767 | } 1768 | } 1769 | }, 1770 | '422': { 1771 | 'description': 'Validation Error', 1772 | 'content': { 1773 | 'application/json': { 1774 | 'schema': { 1775 | '$ref': '#/components/schemas/HTTPValidationError' 1776 | } 1777 | } 1778 | } 1779 | } 1780 | } 1781 | } 1782 | } 1783 | }, 1784 | 'components': { 1785 | 'schemas': { 1786 | 'HTTPValidationError': { 1787 | 'properties': { 1788 | 'detail': { 1789 | 'items': { 1790 | '$ref': '#/components/schemas/ValidationError' 1791 | }, 1792 | 'type': 'array', 1793 | 'title': 'Detail' 1794 | } 1795 | }, 1796 | 'type': 'object', 1797 | 'title': 'HTTPValidationError' 1798 | }, 1799 | 'ItemV2': { 1800 | 'properties': { 1801 | 'id': { 1802 | 'type': 'integer', 1803 | 'title': 'Id' 1804 | }, 1805 | 'name': { 1806 | 'type': 'string', 1807 | 'title': 'Name' 1808 | }, 1809 | 'cost': { 1810 | 'type': 'integer', 1811 | 'title': 'Cost' 1812 | } 1813 | }, 1814 | 'type': 'object', 1815 | 'required': [ 1816 | 'id', 1817 | 'name', 1818 | 'cost' 1819 | ], 1820 | 'title': 'ItemV2' 1821 | }, 1822 | 'UserV2': { 1823 | 'properties': { 1824 | 'id': { 1825 | 'type': 'integer', 1826 | 'title': 'Id' 1827 | }, 1828 | 'name': { 1829 | 'type': 'string', 1830 | 'title': 'Name' 1831 | }, 1832 | 'age': { 1833 | 'type': 'integer', 1834 | 'title': 'Age' 1835 | } 1836 | }, 1837 | 'type': 'object', 1838 | 'required': [ 1839 | 'id', 1840 | 'name', 1841 | 'age' 1842 | ], 1843 | 'title': 'UserV2' 1844 | }, 1845 | 'ValidationError': { 1846 | 'properties': { 1847 | 'loc': { 1848 | 'items': { 1849 | 'anyOf': [ 1850 | { 1851 | 'type': 'string' 1852 | }, 1853 | { 1854 | 'type': 'integer' 1855 | } 1856 | ] 1857 | }, 1858 | 'type': 'array', 1859 | 'title': 'Location' 1860 | }, 1861 | 'msg': { 1862 | 'type': 'string', 1863 | 'title': 'Message' 1864 | }, 1865 | 'type': { 1866 | 'type': 'string', 1867 | 'title': 'Error Type' 1868 | } 1869 | }, 1870 | 'type': 'object', 1871 | 'required': [ 1872 | 'loc', 1873 | 'msg', 1874 | 'type' 1875 | ], 1876 | 'title': 'ValidationError' 1877 | } 1878 | } 1879 | } 1880 | }, 1881 | test_client.get('/latest/api_schema.json').json() 1882 | ) 1883 | --------------------------------------------------------------------------------