Skip to main content

Router-based API versioning for FastAPI, with per-version docs and a declarative route lifecycle.

Project description

FastAPI Router Versioning

PyPI License: MIT Python

Router-based API versioning for FastAPI.

FastAPI has no built-in versioning mechanism. The common workaround — duplicating routers or managing prefixes manually — breaks down quickly as the number of versions grows. This package adds declarative versioning directly on routes, with isolated URL prefixes, per-version Swagger UI, and a full route lifecycle, without touching the existing application structure.


Features

  • SemVer and CalVer — version routes with (major, minor) tuples or arbitrary strings
  • Per-version docs — isolated Swagger UI, ReDoc, and openapi.json for every version
  • Declarative lifecycle — introduce, deprecate, and remove routes with a single decorator
  • Latest alias — serve the newest version under a stable /latest prefix
  • Self-hosted docs — point Swagger UI and ReDoc at your own assets for air-gapped environments
  • Reverse proxy aware — doc URLs include the ASGI root_path at request time, so sub-app mounting works out of the box
  • Broad compatibility — works with nested routers, WebSockets, Depends, and OpenAPI Callbacks

Requirements

  • Python ≥ 3.10
  • FastAPI ≥ 0.120.0

Installation

pip install fastapi-router-versioning
# or
uv add fastapi-router-versioning

Quick start — SemVer

from fastapi import APIRouter, FastAPI
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version

app = FastAPI()
router = APIRouter()


@router.get("/items")
@api_version((1, 0))
def get_items_v1():
    return {"version": "1.0", "items": ["a", "b"]}


@router.get("/items")
@api_version((2, 0))
def get_items_v2():
    return {"version": "2.0", "items": ["a", "b", "c"]}


RouterVersioner(app=app, routers=router, version_format=VersionFormat.SEMVER).versionize()
# Mounts: GET /v1_0/items   GET /v2_0/items

Each version gets its own Swagger UI at /v1_0/docs, /v2_0/docs, and so on.


Quick start — CalVer

from fastapi import APIRouter, FastAPI
from fastapi_router_versioning import RouterVersioner, VersionFormat, api_version

app = FastAPI()
router = APIRouter()


@router.get("/items")
@api_version("2025-01-01")
def get_items():
    return {"release": "2025-01-01"}


RouterVersioner(app=app, routers=router, version_format=VersionFormat.CALVER).versionize()
# Mounts: GET /2025-01-01/items

Valid CalVer tokens: "2025-01-01", "v3", "stable", etc.

CalVer sorting: versions are sorted lexicographically, so tokens must be comparable in the intended order. ISO dates ("2025-01-01") and zero-padded numbers ("v01", "v02") work correctly. Non-padded strings like "v1", "v10", "v2" will not sort correctly and will cause routes to appear in the wrong versions.


Route lifecycle

Use deprecate_in and remove_in to manage the full lifecycle of a route across versions.

@router.get("/legacy")
@api_version((1, 0), deprecate_in=(2, 0), remove_in=(3, 0))
def legacy_route():
    return {"msg": "I am stable in v1, deprecated in v2, gone in v3."}
Version /legacy present? Marked deprecated?
v1.0 yes no
v2.0 yes yes
v3.0 no

Routes without @api_version fall back to default_version (default: (1, 0) for SemVer, "1" for CalVer).


RouterVersioner reference

Parameter Type Default Description
app FastAPI required The FastAPI application instance
routers APIRouter | list[APIRouter] required Router(s) whose routes will be versioned
version_format VersionFormat SEMVER Versioning strategy (SEMVER or CALVER)
prefix_format str | None /v{major}_{minor} / /{version} URL prefix template; supports {major}, {minor}, {version}
semantic_version_format str | None {major}.{minor} / {version} Version label used in Swagger/ReDoc titles
default_version VersionT | None (1, 0) / "1" Fallback version for routes without @api_version
latest_prefix str | None None If set, adds an alias prefix (e.g. "/latest") pointing to the newest version
include_version_docs bool True Create per-version Swagger UI and ReDoc pages
include_version_openapi_route bool True Create a per-version openapi.json route
include_versions_route bool False Add a GET /versions endpoint listing all active versions
sort_routes bool False Sort routes alphabetically by path within each version
callback Callable[[APIRouter, VersionT, str], None] | None None Hook called once per versioned router, before it is included in the app
webhook_routers APIRouter | list[APIRouter] | None None Router(s) containing webhook definitions annotated with @api_version; each version's schema shows only the webhooks active in that version
openapi_hook Callable[[dict, VersionT], dict] | None None Hook applied to the generated OpenAPI schema for each version; receives (schema, version) and must return the modified schema
swagger_js_url str | None FastAPI CDN Custom URL for the Swagger UI JS bundle
swagger_css_url str | None FastAPI CDN Custom URL for the Swagger UI CSS
swagger_favicon_url str | None FastAPI favicon Custom URL for the Swagger UI favicon
redoc_js_url str | None FastAPI CDN Custom URL for the ReDoc JS bundle
redoc_favicon_url str | None FastAPI favicon Custom URL for the ReDoc favicon
redoc_with_google_fonts bool True If False, ReDoc will not load Google Fonts

Call .versionize() after constructing the object. It returns the list of active versions.


@api_version reference

@api_version(version, *, deprecate_in=None, remove_in=None)
Parameter Type Required Description
version tuple[int, int] | str yes First version in which this route is active
deprecate_in same | None no Version in which this route is marked deprecated in the docs
remove_in same | None no Version from which this route is removed entirely

All three parameters must match the version_format configured on RouterVersioner (tuple[int, int] for SemVer, str for CalVer).


Advanced options

Latest alias

Serve the newest version under a stable prefix that clients can pin to:

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    latest_prefix="/latest",
).versionize()
# Also mounts /latest/... pointing to the highest version

Version discovery endpoint

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    include_versions_route=True,
).versionize()
GET /versions
{
  "versions": [
    {
      "version": "1.0",
      "openapi_url": "/v1_0/openapi.json",
      "swagger_url": "/v1_0/docs",
      "redoc_url": "/v1_0/redoc"
    }
  ]
}

Custom URL format

Use prefix_format and semantic_version_format to control how versions appear in URLs and docs.

Major-only versioning (/v1, /v2, /v3):

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    prefix_format="/v{major}",
    semantic_version_format="{major}",
    latest_prefix="/latest",
).versionize()
# Mounts: GET /v1/items   GET /v2/items   GET /latest/items
# Swagger at /v1/docs, /v2/docs — titles show "v1", "v2"

The route decorator still uses (major, minor) tuples — only the URL and doc label change.

OpenAPI schema hook

openapi_hook lets you modify the generated OpenAPI JSON for each version — useful for custom extensions, logos, version-specific metadata, or AWS API Gateway integration. Unlike overriding app.openapi, this hook is called inside the per-version generation pipeline, so it receives the correct filtered schema.

def my_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
    # Applied to every version
    schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}

    # Applied only to v1
    if version == (1, 0):
        schema["info"]["description"] += "\n\n**DEPRECATED:** Use v2."

    return schema

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    openapi_hook=my_openapi_hook,
).versionize()

The hook receives (schema: dict, version: VersionT) and must return the modified dict.

OpenAPI Callbacks and Webhooks

Callbacks (per-route) are propagated automatically — any callbacks=[...] parameter on a route is copied to every versioned copy of that route:

callback_router = APIRouter()

@callback_router.post("{$url}")
def on_event(body: dict) -> None: ...

@router.post("/items", callbacks=callback_router.routes)
@api_version((1, 0))
def create_item() -> dict: ...

Webhooks (app.webhooks) appear in the OpenAPI schema of every version by default. To version webhooks independently, use webhook_routers with the same @api_version decorator used on regular routes:

webhook_router = APIRouter()

@webhook_router.post("/order-created")
@api_version((1, 0))
def webhook_order_v1(body: OrderV1) -> None: ...

@webhook_router.post("/order-created")
@api_version((2, 0))       # replaces v1 definition (same path + method)
def webhook_order_v2(body: OrderV2) -> None: ...

@webhook_router.post("/payment-failed")
@api_version((1, 0), remove_in=(2, 0))
def webhook_payment_v1(body: dict) -> None: ...

RouterVersioner(
    app=app,
    routers=router,
    webhook_routers=webhook_router,
    version_format=VersionFormat.SEMVER,
).versionize()
# /v1_0/openapi.json → webhooks: /order-created (V1), /payment-failed
# /v2_0/openapi.json → webhooks: /order-created (V2)  ← /payment-failed removed

The same remove_in lifecycle applies. A new webhook version only appears once a route version creates that API prefix.

Multiple routers

Pass a list of routers to version routes split across modules:

RouterVersioner(
    app=app,
    routers=[users_router, products_router],
    version_format=VersionFormat.SEMVER,
).versionize()

All routers are versioned together under the same prefix tree.

Self-hosted docs (air-gapped environments)

By default, Swagger UI and ReDoc assets are loaded from the FastAPI CDN. In air-gapped or corporate environments, point them at locally hosted assets:

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    swagger_js_url="/static/swagger-ui-bundle.js",
    swagger_css_url="/static/swagger-ui.css",
    swagger_favicon_url="/static/favicon.png",
    redoc_js_url="/static/redoc.standalone.js",
    redoc_favicon_url="/static/favicon.png",
    redoc_with_google_fonts=False,
).versionize()

See examples/download_static_assets.py for a script that downloads all required assets in one step, and examples/self_hosted_docs_app.py for a complete working example.

Reverse proxy / sub-app mounting

When the app runs behind a reverse proxy or is mounted as a sub-application, the ASGI root_path is included in all per-version doc URLs automatically — no extra configuration needed:

parent = FastAPI()
parent.mount("/api", app)  # root_path="/api" is injected at request time
# /api/v1_0/docs correctly references /api/v1_0/openapi.json

Callback hook

Run custom logic each time a versioned router is created — useful for logging or metrics:

def on_version_created(router: APIRouter, version, prefix: str) -> None:
    print(f"Registered version {version} at {prefix}")

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    callback=on_version_created,
).versionize()

Examples

Runnable examples are available in the examples/ directory:

File What it shows
semver_app.py Full SemVer lifecycle (introduce, deprecate, remove)
calver_app.py Same lifecycle with CalVer date strings
semver_major_only_app.py Custom prefix /v1, /v2 via prefix_format
webhook_versioning_app.py Per-version webhook definitions via webhook_routers
multi_router_app.py Multiple routers versioned together
self_hosted_docs_app.py Swagger UI and ReDoc from local static assets

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_router_versioning-0.1.2.tar.gz (91.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_router_versioning-0.1.2-py3-none-any.whl (13.8 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_router_versioning-0.1.2.tar.gz.

File metadata

  • Download URL: fastapi_router_versioning-0.1.2.tar.gz
  • Upload date:
  • Size: 91.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_router_versioning-0.1.2.tar.gz
Algorithm Hash digest
SHA256 6e6d658f3334ccd0c6be1449eb9397ba842ef6ae907fd24754b74c2c9fa4d49e
MD5 ff50e8b68dfc47827ecb5eef39b0907e
BLAKE2b-256 0c3112ecd24e9776128f9eb61af6fdb67431029ea102b4a95538f7383119558c

See more details on using hashes here.

File details

Details for the file fastapi_router_versioning-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: fastapi_router_versioning-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 13.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for fastapi_router_versioning-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 79e93cb0fb31e36f2b1a9533447487c34ea68494325a3a0d62b4e59e7657e5c7
MD5 8c3a65cca7de17d016357a59ecc041ac
BLAKE2b-256 c19a5c8bd7862524e6795bc895a93014237cc956c3fbe36a027953b6cf5af8e4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page