├── .bumpversion.cfg ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── LICENSE ├── Pipfile ├── README.md ├── example ├── __init__.py ├── annotation │ ├── __init__.py │ ├── app.py │ ├── item.py │ └── store.py ├── custom_default_version │ ├── __init__.py │ └── app.py ├── proxy │ ├── README.md │ ├── __init__.py │ ├── app.py │ ├── routes.toml │ └── traefik.toml └── router │ ├── __init__.py │ ├── app.py │ ├── v1_0.py │ └── v1_1.py ├── fastapi_versioning ├── __init__.py ├── routing.py └── versioning.py ├── mypy.ini ├── pyproject.toml ├── sample.py ├── scripts ├── build.sh ├── bumpversion.sh ├── ci.sh ├── lint-fix.sh ├── lint.sh ├── static-analysis.sh ├── test.sh ├── type-check.sh └── upload.sh ├── setup.py ├── tests ├── test_example.py └── unit │ ├── __init__.py │ └── test_root_path.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.10.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | message = Bump version from {current_version} to {new_version} 7 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)((-rc(?P\d+))?) 8 | serialize = 9 | {major}.{minor}.{patch}-rc{rc} 10 | {major}.{minor}.{patch} 11 | 12 | [bumpversion:file:setup.py] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 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 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # editor config 108 | .vscode/ 109 | .idea/ 110 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: pip install tox-travis 3 | script: tox 4 | stages: 5 | - name: test 6 | jobs: 7 | include: 8 | - stage: test 9 | python: 3.6 10 | - stage: test 11 | python: 3.7 12 | - stage: test 13 | python: 3.8 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dean Way 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 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | uvicorn = "*" 8 | tox = "*" 9 | pytest = "*" 10 | typing_extensions = "*" 11 | bumpversion = "*" 12 | requests = "*" 13 | mypy = "*" 14 | black = "==20.8b1" 15 | isort = "*" 16 | flake8 = "*" 17 | 18 | [packages] 19 | fastapi = ">=0.56.0" 20 | starlette = "==0.13.6" 21 | fastapi-versioning = {path = ".",editable = true} 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-versioning 2 | api versioning for fastapi web applications 3 | 4 | # Installation 5 | 6 | `pip install fastapi-versioning` 7 | 8 | ## Examples 9 | ```python 10 | from fastapi import FastAPI 11 | from fastapi_versioning import VersionedFastAPI, version 12 | 13 | app = FastAPI(title="My App") 14 | 15 | 16 | @app.get("/greet") 17 | @version(1, 0) 18 | def greet_with_hello(): 19 | return "Hello" 20 | 21 | 22 | @app.get("/greet") 23 | @version(1, 1) 24 | def greet_with_hi(): 25 | return "Hi" 26 | 27 | 28 | app = VersionedFastAPI(app) 29 | ``` 30 | 31 | this will generate two endpoints: 32 | ``` 33 | /v1_0/greet 34 | /v1_1/greet 35 | ``` 36 | as well as: 37 | ``` 38 | /docs 39 | /v1_0/docs 40 | /v1_1/docs 41 | /v1_0/openapi.json 42 | /v1_1/openapi.json 43 | ``` 44 | 45 | There's also the possibility of adding a set of additional endpoints that 46 | redirect the most recent API version. To do that make the argument 47 | `enable_latest` true: 48 | 49 | ```python 50 | app = VersionedFastAPI(app, enable_latest=True) 51 | ``` 52 | 53 | this will generate the following additional endpoints: 54 | ``` 55 | /latest/greet 56 | /latest/docs 57 | /latest/openapi.json 58 | ``` 59 | In this example, `/latest` endpoints will reflect the same data as `/v1.1`. 60 | 61 | Try it out: 62 | ```sh 63 | pip install pipenv 64 | pipenv install --dev 65 | pipenv run uvicorn example.annotation.app:app 66 | # pipenv run uvicorn example.folder_name.app:app 67 | ``` 68 | 69 | ## Usage without minor version 70 | ```python 71 | from fastapi import FastAPI 72 | from fastapi_versioning import VersionedFastAPI, version 73 | 74 | app = FastAPI(title='My App') 75 | 76 | @app.get('/greet') 77 | @version(1) 78 | def greet(): 79 | return 'Hello' 80 | 81 | @app.get('/greet') 82 | @version(2) 83 | def greet(): 84 | return 'Hi' 85 | 86 | app = VersionedFastAPI(app, 87 | version_format='{major}', 88 | prefix_format='/v{major}') 89 | ``` 90 | 91 | this will generate two endpoints: 92 | ``` 93 | /v1/greet 94 | /v2/greet 95 | ``` 96 | as well as: 97 | ``` 98 | /docs 99 | /v1/docs 100 | /v2/docs 101 | /v1/openapi.json 102 | /v2/openapi.json 103 | ``` 104 | 105 | ## Extra FastAPI constructor arguments 106 | 107 | It's important to note that only the `title` from the original FastAPI will be 108 | provided to the VersionedAPI app. If you have any middleware, event handlers 109 | etc these arguments will also need to be provided to the VersionedAPI function 110 | call, as in the example below 111 | 112 | ```python 113 | from fastapi import FastAPI, Request 114 | from fastapi_versioning import VersionedFastAPI, version 115 | from starlette.middleware import Middleware 116 | from starlette.middleware.sessions import SessionMiddleware 117 | 118 | app = FastAPI( 119 | title='My App', 120 | description='Greet uses with a nice message', 121 | middleware=[ 122 | Middleware(SessionMiddleware, secret_key='mysecretkey') 123 | ] 124 | ) 125 | 126 | @app.get('/greet') 127 | @version(1) 128 | def greet(request: Request): 129 | request.session['last_version_used'] = 1 130 | return 'Hello' 131 | 132 | @app.get('/greet') 133 | @version(2) 134 | def greet(request: Request): 135 | request.session['last_version_used'] = 2 136 | return 'Hi' 137 | 138 | @app.get('/version') 139 | def last_version(request: Request): 140 | return f'Your last greeting was sent from version {request.session["last_version_used"]}' 141 | 142 | app = VersionedFastAPI(app, 143 | version_format='{major}', 144 | prefix_format='/v{major}', 145 | description='Greet users with a nice message', 146 | middleware=[ 147 | Middleware(SessionMiddleware, secret_key='mysecretkey') 148 | ] 149 | ) 150 | ``` 151 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/example/__init__.py -------------------------------------------------------------------------------- /example/annotation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/example/annotation/__init__.py -------------------------------------------------------------------------------- /example/annotation/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from example.annotation import item, store 4 | from fastapi_versioning import VersionedFastAPI 5 | 6 | app = FastAPI(title="My Online Store") 7 | app.include_router(store.router) 8 | app.include_router(item.router) 9 | app = VersionedFastAPI(app, enable_latest=True) 10 | -------------------------------------------------------------------------------- /example/annotation/item.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi.routing import APIRouter 4 | from pydantic import BaseModel 5 | 6 | from fastapi_versioning import version 7 | 8 | router = APIRouter() 9 | 10 | 11 | class Item(BaseModel): 12 | id: str 13 | name: str 14 | price: float 15 | 16 | 17 | class ItemV1(Item): 18 | quantity: int 19 | 20 | 21 | class ComplexQuantity(BaseModel): 22 | store_id: str 23 | quantity: int 24 | 25 | 26 | class ItemV2(Item): 27 | quantity: List[ComplexQuantity] 28 | 29 | 30 | @router.get("/item/{item_id}", response_model=ItemV1) 31 | @version(1, 1) 32 | def get_item_v1(item_id: str) -> ItemV1: 33 | return ItemV1( 34 | id=item_id, 35 | name="ice cream", 36 | price=1.2, 37 | quantity=5, 38 | ) 39 | 40 | 41 | @router.get("/item/{item_id}", response_model=ItemV2) 42 | @version(1, 2) 43 | def get_item_v2(item_id: str) -> ItemV2: 44 | return ItemV2( 45 | id=item_id, 46 | name="ice cream", 47 | price=1.2, 48 | quantity=[{"store_id": "1", "quantity": 5}], 49 | ) 50 | 51 | 52 | @router.delete("/item/{item_id}") 53 | @version(1, 2) 54 | def delete_item(item_id: str) -> None: 55 | return None 56 | 57 | 58 | @router.post("/item", response_model=ItemV2) 59 | @version(1, 3) 60 | def create_item(item: ItemV2) -> ItemV2: 61 | return item 62 | -------------------------------------------------------------------------------- /example/annotation/store.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn 2 | 3 | from fastapi.exceptions import HTTPException 4 | from fastapi.routing import APIRouter 5 | from pydantic import BaseModel 6 | from typing_extensions import Literal 7 | 8 | from fastapi_versioning import version 9 | 10 | router = APIRouter() 11 | 12 | 13 | class StoreCommon(BaseModel): 14 | id: str 15 | name: str 16 | country: str 17 | 18 | 19 | class StoreV1(StoreCommon): 20 | status: bool 21 | 22 | 23 | class StoreV2(StoreCommon): 24 | status: Literal["open", "closed", "closed_permanently"] 25 | 26 | 27 | @router.get("/store/{store_id}", response_model=StoreV1) 28 | def get_store_v1(store_id: str) -> StoreV1: 29 | return StoreV1( 30 | id=store_id, 31 | name="ice cream shoppe", 32 | country="Canada", 33 | status=True, 34 | ) 35 | 36 | 37 | @router.get("/store/{store_id}", response_model=StoreV2) 38 | @version(1, 1) 39 | def get_store_v2(store_id: str) -> StoreV2: 40 | return StoreV2( 41 | id=store_id, 42 | name="ice cream shoppe", 43 | country="Canada", 44 | status="open", 45 | ) 46 | 47 | 48 | @router.get("/store/{store_id}", include_in_schema=False) 49 | @version(1, 3) 50 | def get_store_v3(store_id: str) -> NoReturn: 51 | raise HTTPException(status_code=404) 52 | -------------------------------------------------------------------------------- /example/custom_default_version/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/example/custom_default_version/__init__.py -------------------------------------------------------------------------------- /example/custom_default_version/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_versioning import VersionedFastAPI, version 4 | 5 | app = FastAPI(title="My Online Store") 6 | 7 | 8 | @app.get("/") 9 | def home() -> str: 10 | return "Hello default version 2.0!" 11 | 12 | 13 | @app.get("/") 14 | @version(3, 0) 15 | def home_v3() -> str: 16 | return "Hello version 3.0!" 17 | 18 | 19 | app = VersionedFastAPI(app, default_version=(2, 0)) 20 | -------------------------------------------------------------------------------- /example/proxy/README.md: -------------------------------------------------------------------------------- 1 | ### Running this example: 2 | install traefik 3 | 4 | run: 5 | ```sh 6 | traefik --configFile=traefik.toml 7 | ``` 8 | 9 | then in another shell run: 10 | ```sh 11 | uvicorn app:app 12 | ``` 13 | 14 | alternatively, delete `root_path="/api"` from `app.py` and run: 15 | ```sh 16 | uvicorn app:app --root-path /api 17 | ``` 18 | -------------------------------------------------------------------------------- /example/proxy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/example/proxy/__init__.py -------------------------------------------------------------------------------- /example/proxy/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_versioning import VersionedFastAPI, version 4 | 5 | app = FastAPI(title="My App") 6 | 7 | 8 | @app.get("/greet") 9 | @version(1, 0) 10 | def greet_with_hello() -> str: 11 | return "Hello" 12 | 13 | 14 | @app.get("/greet") 15 | @version(1, 1) 16 | def greet_with_hi() -> str: 17 | return "Hi" 18 | 19 | 20 | app = VersionedFastAPI(app, root_path="/api") 21 | -------------------------------------------------------------------------------- /example/proxy/routes.toml: -------------------------------------------------------------------------------- 1 | [http] 2 | [http.middlewares] 3 | 4 | [http.middlewares.api-stripprefix.stripPrefix] 5 | prefixes = ["/api"] 6 | 7 | [http.routers] 8 | 9 | [http.routers.app-http] 10 | entryPoints = ["http"] 11 | service = "app" 12 | rule = "PathPrefix(`/api`)" 13 | middlewares = ["api-stripprefix"] 14 | 15 | [http.services] 16 | 17 | [http.services.app] 18 | [http.services.app.loadBalancer] 19 | [[http.services.app.loadBalancer.servers]] 20 | url = "http://127.0.0.1:8000" 21 | -------------------------------------------------------------------------------- /example/proxy/traefik.toml: -------------------------------------------------------------------------------- 1 | [entryPoints] 2 | [entryPoints.http] 3 | address = ":9999" 4 | 5 | [providers] 6 | [providers.file] 7 | filename = "routes.toml" 8 | -------------------------------------------------------------------------------- /example/router/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/example/router/__init__.py -------------------------------------------------------------------------------- /example/router/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from example.router import v1_0, v1_1 4 | from fastapi_versioning import VersionedFastAPI 5 | 6 | app = FastAPI() 7 | app.include_router(v1_0.router) 8 | app.include_router(v1_1.router) 9 | app = VersionedFastAPI(app) 10 | -------------------------------------------------------------------------------- /example/router/v1_0.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | 3 | router = APIRouter() 4 | 5 | 6 | @router.get("/greet") 7 | def greet() -> str: 8 | return "Hello" 9 | -------------------------------------------------------------------------------- /example/router/v1_1.py: -------------------------------------------------------------------------------- 1 | from fastapi.routing import APIRouter 2 | 3 | from fastapi_versioning import versioned_api_route 4 | 5 | router = APIRouter(route_class=versioned_api_route(1, 1)) 6 | 7 | 8 | @router.get("/greet") 9 | def greet() -> str: 10 | return "Hi" 11 | 12 | 13 | @router.delete("/greet") 14 | def goodbye() -> str: 15 | return "Goodbye" 16 | -------------------------------------------------------------------------------- /fastapi_versioning/__init__.py: -------------------------------------------------------------------------------- 1 | from .routing import versioned_api_route 2 | from .versioning import VersionedFastAPI, version 3 | 4 | __all__ = [ 5 | "VersionedFastAPI", 6 | "versioned_api_route", 7 | "version", 8 | ] 9 | -------------------------------------------------------------------------------- /fastapi_versioning/routing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Type 2 | 3 | from fastapi.routing import APIRoute 4 | 5 | 6 | def versioned_api_route( 7 | major: int = 1, minor: int = 0, route_class: Type[APIRoute] = APIRoute 8 | ) -> Type[APIRoute]: 9 | class VersionedAPIRoute(route_class): # type: ignore 10 | def __init__(self, *args: Any, **kwargs: Any) -> None: 11 | super().__init__(*args, **kwargs) 12 | try: 13 | self.endpoint._api_version = (major, minor) 14 | except AttributeError: 15 | # Support bound methods 16 | self.endpoint.__func__._api_version = (major, minor) 17 | 18 | return VersionedAPIRoute 19 | -------------------------------------------------------------------------------- /fastapi_versioning/versioning.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Any, Callable, Dict, List, Tuple, TypeVar, cast 3 | 4 | from fastapi import FastAPI 5 | from fastapi.routing import APIRoute 6 | from starlette.routing import BaseRoute 7 | 8 | CallableT = TypeVar("CallableT", bound=Callable[..., Any]) 9 | 10 | 11 | def version(major: int, minor: int = 0) -> Callable[[CallableT], CallableT]: 12 | def decorator(func: CallableT) -> CallableT: 13 | func._api_version = (major, minor) # type: ignore 14 | return func 15 | 16 | return decorator 17 | 18 | 19 | def version_to_route( 20 | route: BaseRoute, 21 | default_version: Tuple[int, int], 22 | ) -> Tuple[Tuple[int, int], APIRoute]: 23 | api_route = cast(APIRoute, route) 24 | version = getattr(api_route.endpoint, "_api_version", default_version) 25 | return version, api_route 26 | 27 | 28 | def VersionedFastAPI( 29 | app: FastAPI, 30 | version_format: str = "{major}.{minor}", 31 | prefix_format: str = "/v{major}_{minor}", 32 | default_version: Tuple[int, int] = (1, 0), 33 | enable_latest: bool = False, 34 | **kwargs: Any, 35 | ) -> FastAPI: 36 | parent_app = FastAPI( 37 | title=app.title, 38 | **kwargs, 39 | ) 40 | version_route_mapping: Dict[Tuple[int, int], List[APIRoute]] = defaultdict( 41 | list 42 | ) 43 | version_routes = [ 44 | version_to_route(route, default_version) for route in app.routes 45 | ] 46 | 47 | for version, route in version_routes: 48 | version_route_mapping[version].append(route) 49 | 50 | unique_routes = {} 51 | versions = sorted(version_route_mapping.keys()) 52 | for version in versions: 53 | major, minor = version 54 | prefix = prefix_format.format(major=major, minor=minor) 55 | semver = version_format.format(major=major, minor=minor) 56 | versioned_app = FastAPI( 57 | title=app.title, 58 | description=app.description, 59 | version=semver, 60 | ) 61 | for route in version_route_mapping[version]: 62 | for method in route.methods: 63 | unique_routes[route.path + "|" + method] = route 64 | for route in unique_routes.values(): 65 | versioned_app.router.routes.append(route) 66 | parent_app.mount(prefix, versioned_app) 67 | 68 | @parent_app.get( 69 | f"{prefix}/openapi.json", name=semver, tags=["Versions"] 70 | ) 71 | @parent_app.get(f"{prefix}/docs", name=semver, tags=["Documentations"]) 72 | def noop() -> None: 73 | ... 74 | 75 | if enable_latest: 76 | prefix = "/latest" 77 | major, minor = version 78 | semver = version_format.format(major=major, minor=minor) 79 | versioned_app = FastAPI( 80 | title=app.title, 81 | description=app.description, 82 | version=semver, 83 | ) 84 | for route in unique_routes.values(): 85 | versioned_app.router.routes.append(route) 86 | parent_app.mount(prefix, versioned_app) 87 | 88 | return parent_app 89 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta:__legacy__" 4 | 5 | [tool.isort] 6 | profile = "black" 7 | 8 | [tool.black] 9 | line-length = 79 10 | -------------------------------------------------------------------------------- /sample.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from fastapi_versioning import VersionedFastAPI, version 4 | 5 | app = FastAPI(title="My App") 6 | 7 | 8 | @app.get("/") 9 | @version(1, 0) 10 | def greet_with_hello(): 11 | return "Hello" 12 | 13 | 14 | @app.get("/") 15 | @version(1, 1) 16 | def greet_with_hi(): 17 | return "Hi" 18 | 19 | 20 | app = VersionedFastAPI(app) 21 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | rm -r dist/ 6 | python -m build 7 | -------------------------------------------------------------------------------- /scripts/bumpversion.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | git fetch origin 6 | git checkout -B master 7 | git reset --soft origin/master 8 | bumpversion "$@" 9 | git push 10 | git push --tags 11 | -------------------------------------------------------------------------------- /scripts/ci.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | 4 | ./static-analysis.sh 5 | ./test.sh 6 | -------------------------------------------------------------------------------- /scripts/lint-fix.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | isort . 6 | black . 7 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | isort --check . 6 | black --check . 7 | flake8 . 8 | -------------------------------------------------------------------------------- /scripts/static-analysis.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | 4 | ./lint.sh 5 | ./type-check.sh 6 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | pytest tests/ 6 | -------------------------------------------------------------------------------- /scripts/type-check.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | mypy fastapi_versioning/ 6 | mypy tests/ 7 | mypy example/ 8 | -------------------------------------------------------------------------------- /scripts/upload.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -e 2 | cd "$( dirname "${BASH_SOURCE[0]}" )" 3 | cd .. 4 | 5 | twine upload dist/* 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="fastapi_versioning", 8 | version="0.10.0", 9 | author="Dean Way", 10 | description="api versioning for fastapi web applications", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/DeanWay/fastapi-versioning", 14 | packages=["fastapi_versioning"], 15 | classifiers=[ 16 | "Programming Language :: Python :: 3.6", 17 | "Programming Language :: Python :: 3.7", 18 | "Programming Language :: Python :: 3.8", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | install_requires=[ 23 | "fastapi>=0.56.0", 24 | "starlette", 25 | ], 26 | python_requires=">=3.6", 27 | ) 28 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | from starlette.testclient import TestClient 2 | 3 | from example.annotation.app import app as annotation_app 4 | from example.custom_default_version.app import app as default_version_app 5 | from example.router.app import app as router_app 6 | 7 | 8 | def test_annotation_app() -> None: 9 | test_client = TestClient(annotation_app) 10 | assert test_client.get("/docs").status_code == 200 11 | assert test_client.get("/v1_0/docs").status_code == 200 12 | assert test_client.get("/v1_1/docs").status_code == 200 13 | assert test_client.get("/v1_2/docs").status_code == 200 14 | assert test_client.get("/v1_3/docs").status_code == 200 15 | assert test_client.get("/latest/docs").status_code == 200 16 | assert test_client.get("/v1_4/docs").status_code == 404 17 | 18 | assert test_client.get("/v1_0/item/1").status_code == 404 19 | assert test_client.get("/v1_1/item/1").status_code == 200 20 | assert test_client.get("/v1_1/item/1").json()["quantity"] == 5 21 | complex_quantity = [{"store_id": "1", "quantity": 5}] 22 | 23 | assert ( 24 | test_client.get("/v1_2/item/1").json()["quantity"] == complex_quantity 25 | ) 26 | assert ( 27 | test_client.get("/v1_3/item/1").json()["quantity"] == complex_quantity 28 | ) 29 | 30 | assert ( 31 | test_client.get("/latest/item/1").json()["quantity"] 32 | == complex_quantity 33 | ) 34 | 35 | item = { 36 | "id": "1", 37 | "name": "apple", 38 | "price": 1.0, 39 | "quantity": complex_quantity, 40 | } 41 | assert ( 42 | test_client.post( 43 | "/v1_3/item", 44 | json=item, 45 | ).json() 46 | == item 47 | ) 48 | assert ( 49 | test_client.post( 50 | "/latest/item", 51 | json=item, 52 | ).json() 53 | == item 54 | ) 55 | 56 | assert test_client.delete("/v1_1/item/1").status_code == 405 57 | assert test_client.delete("/v1_2/item/1").status_code == 200 58 | 59 | assert test_client.get("/v1_0/store/1").status_code == 200 60 | assert test_client.get("/v1_0/store/1").json()["status"] is True 61 | assert test_client.get("/v1_1/store/1").json()["status"] == "open" 62 | assert test_client.get("/v1_2/store/1").json()["status"] == "open" 63 | assert test_client.get("/v1_3/store/1").status_code == 404 64 | 65 | 66 | def test_router_app() -> None: 67 | test_client = TestClient(router_app) 68 | assert test_client.get("/docs").status_code == 200 69 | assert test_client.get("/v1_0/docs").status_code == 200 70 | assert test_client.get("/v1_1/docs").status_code == 200 71 | assert test_client.get("/v1_3/docs").status_code == 404 72 | 73 | assert test_client.get("/v1_0/greet").json() == "Hello" 74 | assert test_client.get("/v1_1/greet").json() == "Hi" 75 | 76 | assert test_client.delete("/v1_0/greet").status_code == 405 77 | assert test_client.delete("/v1_1/greet").status_code == 200 78 | assert test_client.delete("/v1_1/greet").json() == "Goodbye" 79 | 80 | 81 | def test_default_version() -> None: 82 | test_client = TestClient(default_version_app) 83 | assert test_client.get("/docs").status_code == 200 84 | assert test_client.get("/v1_0/docs").status_code == 404 85 | assert test_client.get("/v2_0/docs").status_code == 200 86 | assert test_client.get("/v3_0/docs").status_code == 200 87 | 88 | assert test_client.get("/v2_0/").json() == "Hello default version 2.0!" 89 | assert test_client.get("/v3_0/").json() == "Hello version 3.0!" 90 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeanWay/fastapi-versioning/18d480f5bb067088f157f235a673cb4c65ec77d5/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_root_path.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from fastapi import FastAPI, Request 4 | from starlette.testclient import TestClient 5 | 6 | from fastapi_versioning import VersionedFastAPI 7 | 8 | 9 | def test_root_path() -> None: 10 | root_path = "/custom/root" 11 | parent_app = FastAPI() 12 | 13 | @parent_app.get("/check-root-path") 14 | def check_root_path(request: Request) -> Dict[str, Any]: 15 | return {"root_path": request.scope.get("root_path")} 16 | 17 | versioned_app = VersionedFastAPI(app=parent_app, root_path=root_path) 18 | test_client = TestClient(versioned_app, root_path=root_path) 19 | 20 | response = test_client.get("/v1_0/check-root-path") 21 | assert response.status_code == 200 22 | assert response.json() == {"root_path": "/custom/root/v1_0"} 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38 3 | 4 | [testenv] 5 | deps = pipenv 6 | setenv = 7 | PYTHONPATH={toxinidir} 8 | commands= 9 | pipenv install --dev --system --skip-lock 10 | pipenv run ./scripts/ci.sh 11 | --------------------------------------------------------------------------------