Metadata-Version: 2.5
Name: fastapi-router-versioning
Version: 1.0.2
Summary: Router-based API versioning for FastAPI, with per-version docs and a declarative route lifecycle.
Project-URL: Homepage, https://github.com/mat81black/fastapi-router-versioning
Project-URL: Repository, https://github.com/mat81black/fastapi-router-versioning
Project-URL: Issues, https://github.com/mat81black/fastapi-router-versioning/issues
Project-URL: Documentation, https://github.com/mat81black/fastapi-router-versioning#readme
Project-URL: Changelog, https://github.com/mat81black/fastapi-router-versioning/blob/main/release-notes.md
Author-email: Matteo Nieddu <mat81black@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: fastapi!=0.137.0,!=0.137.1,>=0.120.0
Description-Content-Type: text/markdown

# FastAPI Router Versioning

[![Build Status](https://github.com/mat81black/fastapi-router-versioning/workflows/Test/badge.svg)](https://github.com/mat81black/fastapi-router-versioning/actions)
[![codecov](https://codecov.io/github/mat81black/fastapi-router-versioning/graph/badge.svg?token=4WQ63Q7ESY)](https://codecov.io/github/mat81black/fastapi-router-versioning)
[![pypi package](https://img.shields.io/pypi/v/fastapi-router-versioning?color=%2334D058&label=pypi%20package)](https://pypi.org/project/fastapi-router-versioning/)
[![Supported Python versions](https://img.shields.io/pypi/pyversions/fastapi-router-versioning.svg?color=%2334D058)](https://pypi.org/project/fastapi-router-versioning/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

Running multiple API versions side by side usually means duplicating routers, hand-rolling prefixes, or branching on request paths, and every one of those gets harder to maintain as versions pile up. `RouterVersioner` takes a declarative approach instead: annotate each route with the version it belongs to, and it generates the URL prefixes, the per-version OpenAPI schema, and the docs for you, without moving anything else in your app.

---

## Features

- **SemVer and CalVer**: version routes with `(major, minor)` tuples, or with arbitrary sortable strings
- **Per-version docs**: isolated Swagger UI, ReDoc, and `openapi.json` for every active version
- **Declarative lifecycle**: mark a route's introduction, deprecation, and removal with one decorator
- **Latest alias**: expose the newest version under a fixed `/latest` prefix clients can pin to
- **Self-hosted docs assets**: point Swagger UI and ReDoc at your own JS/CSS for air-gapped deployments
- **Reverse proxy and sub-app aware**: doc URLs pick up the ASGI `root_path` at request time
- **Route composition support**: works across nested routers, WebSockets, `Depends`, and OpenAPI Callbacks

---

## Requirements

- Python ≥ 3.10
- FastAPI ≥ 0.120.0 (`0.137.0` and `0.137.1` are excluded: they shipped a routing internals rewrite before `iter_route_contexts()` landed in `0.137.2`, which this package relies on; supporting that narrow gap would have meant a third compatibility code path instead of the two the package actually needs)

---

## Installation

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

---

## Quick start

`RouterVersioner` is not a request-time dependency, there's no `Depends()` involved. It's a
one-time setup step: you build it, then call `.versionize()` once, and it reads the routers
you gave it and mounts one copy per version. Attach `@api_version` to every route **before**
calling `.versionize()`, since that call is what reads the router and wires everything up;
routes added to the router afterward are never picked up.

### SemVer

```python
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
# Docs at: /v1_0/docs, /v2_0/docs
```

### CalVer

```python
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
```

CalVer tokens can be any string ("2025-01-01", "v3", "stable"...), but they are sorted
lexicographically to determine version order. ISO dates and zero-padded numbers (`"v01"`,
`"v02"`) sort correctly; unpadded strings like `"v1"`, `"v10"`, `"v2"` do not, and will place
routes under the wrong version.

---

## Route lifecycle

`deprecate_in` and `remove_in` describe when a route changes status, without needing a
separate route definition per version:

```python
@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                | n/a                |

`remove_in` is optional: `deprecate_in` alone marks a route deprecated from that version
onward with no planned removal, staying available (and deprecated) in every later version.

A route without `@api_version` isn't excluded, it falls back to `default_version`
(`(1, 0)` for SemVer, `"1"` for CalVer, unless overridden).

FastAPI's own `deprecated=True` (set directly on a route, or inherited from
`APIRouter(deprecated=True)`) is preserved as-is: `RouterVersioner` copies it into every
version unconditionally, on top of whatever `deprecate_in` computes. `deprecate_in` only
ever turns deprecation *on* for versions at or after its boundary, it never turns off a
`deprecated=True` that was already set natively. If you set both on the same route, it
shows as deprecated in every version, including ones before `deprecate_in`'s boundary.
Use one or the other: `deprecated=True` for an unconditional, version-independent flag,
`deprecate_in` for a per-version lifecycle.

A route with `methods=["GET", "POST"]` can have just one of its methods taken over by a
dedicated route in a later version; the other method keeps being served by the original
route. See [`semver_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/semver_app.py)
for a working example.

---

## `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, mounts 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` | Called once per versioned router, right before it's included in the app |
| `webhook_routers` | `APIRouter \| list[APIRouter] \| None` | `None` | Router(s) with webhook definitions annotated via `@api_version`; each version's schema shows only the webhooks active in it |
| `openapi_hook` | `Callable[[dict, VersionT], dict] \| None` | `None` | Called with `(schema, version)` for each generated version schema; must return the (possibly 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` | Set `False` to stop ReDoc from loading Google Fonts |

`.versionize()` returns the list of versions it activated. It can only be called once per
instance; a second call raises `RuntimeError`, since it mutates the live FastAPI app in a way
that can't be undone.

---

## `@api_version` reference

```python
@api_version(version, *, deprecate_in=None, remove_in=None)
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `version` | `tuple[int, int] \| str` | yes | Version in which the route first appears |
| `deprecate_in` | same type \| `None` | no | Version from which the route is flagged deprecated in the docs |
| `remove_in` | same type \| `None` | no | Version from which the route stops being mounted |

`version`, `deprecate_in`, and `remove_in` must all match the `version_format` in use on
the `RouterVersioner` that will process the route (`tuple[int, int]` for SemVer, `str` for
CalVer).

---

## Advanced options

### Latest alias

```python
RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    latest_prefix="/latest",
).versionize()
# /latest/... now points at whichever version is currently highest
```

### Version discovery endpoint

```python
RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    include_versions_route=True,
).versionize()
```

```json
GET /versions
{
  "versions": [
    {
      "version": "1.0",
      "openapi_url": "/v1_0/openapi.json",
      "swagger_url": "/v1_0/docs",
      "redoc_url": "/v1_0/redoc"
    }
  ]
}
```

If several `RouterVersioner` instances share one app and all set `include_versions_route=True`,
`/versions` is mounted once and lists every instance's versions together, instead of the
first instance shadowing the rest (see [Multiple routers](#multiple-routers)).

### Custom URL format

`prefix_format` and `semantic_version_format` control how a version renders in URLs and in
doc titles, independently of how it's expressed in `@api_version`. A common use is dropping
the minor number from the URL while still tracking it internally:

```python
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 titles read "v1", "v2" instead of "v1.0", "v2.0"
```

Routes are still decorated with `(major, minor)` tuples; only their URL and label change.

### OpenAPI schema hook

`openapi_hook` runs inside the per-version schema generation pipeline, so unlike patching
`app.openapi` yourself, it always receives the already-filtered schema for that specific
version:

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

    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()
```

### Custom validation error status code

Changing FastAPI's default `422` for request validation errors is not something
`RouterVersioner` does itself: it's a separate, general-purpose concern, handled by the
[fastapi-validation-override](https://pypi.org/project/fastapi-validation-override/) package.
`openapi_hook` is the integration point: it lets you re-apply the same patch to every
per-version schema that `RouterVersioner` generates, so all of them, root schema included,
stay consistent.

```bash
uv add fastapi-validation-override
```

```python
from fastapi_validation_override import override_validation_error, patch_422_responses

# 1. Registers the runtime handler and patches the app's own root /openapi.json.
override_validation_error(app, status_code=400)

# 2. Re-applies the same patch to each version's own schema.
def versioning_openapi_hook(schema: dict, version: tuple[int, int]) -> dict:
    return patch_422_responses(schema, "400")

RouterVersioner(
    app=app,
    routers=router,
    version_format=VersionFormat.SEMVER,
    openapi_hook=versioning_openapi_hook,
).versionize()
# Validation failures now return 400, both at runtime and in every schema
# (root, and every /vX_Y/openapi.json)
```

See [`examples/validation_override_integration_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/validation_override_integration_app.py).

### OpenAPI Callbacks and Webhooks

Route-level **Callbacks** need no special handling: a `callbacks=[...]` argument on a route
is carried over to every versioned copy of it automatically:

```python
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`) are visible in every version's schema by default. Pass
`webhook_routers` to version them the same way as regular routes, with `@api_version` on
each definition:

```python
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))       # same path + method as v1: replaces it, doesn't add a second entry
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 lists: order-created (v1 payload), payment-failed
# /v2_0/openapi.json lists: order-created (v2 payload)   payment-failed is gone
```

A webhook version only becomes visible once a route version reaches that same prefix, since
both follow the same `remove_in` lifecycle.

### Multiple routers

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

Both routers are versioned together, sharing the same prefix tree, so this is the way to
split a versioned API across modules without creating a second `RouterVersioner`.

### Self-hosted docs assets

Swagger UI and ReDoc load their JS/CSS from FastAPI's CDN by default. Point them at your own
copies for air-gapped or restricted-network deployments:

```python
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()
```

[`examples/download_static_assets.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/download_static_assets.py) downloads the required files in one step;
[`examples/self_hosted_docs_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/self_hosted_docs_app.py) wires them into a full app.

Sharing one app across several `RouterVersioner` instances only makes sense for one reason:
mixing `version_format` values, SemVer for one group of routes and CalVer for another, on
the same app. Splitting modules that share a `version_format` doesn't need a second instance;
pass them all to one `RouterVersioner` via `routers=[...]` instead (see
[`multi_router_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/multi_router_app.py)).
If you do share an app across instances, one rule is enforced for you: every instance needs
its own `prefix_format`/`latest_prefix`. Two instances that resolve to the same prefix would
otherwise overwrite each other's docs/openapi routes at the same path; this raises
`RuntimeError`. `/versions` (see [Version discovery endpoint](#version-discovery-endpoint))
doesn't need this coordination: every instance's contribution is aggregated into the same
endpoint automatically.

For modules that genuinely don't need to coordinate at all, mount them as separate FastAPI
sub-applications instead (next section).

### Reverse proxy and sub-application mounting

The ASGI `root_path` FastAPI sets when an app runs behind a proxy or is mounted with
`app.mount()` is picked up automatically in every per-version doc URL:

```python
parent = FastAPI()
parent.mount("/api", app)  # root_path="/api" is injected per request
# /api/v1_0/docs correctly points at /api/v1_0/openapi.json
```

A mounted sub-application is a separate `FastAPI()` instance with its own `app.state`, so a
`RouterVersioner` attached to it is entirely independent from one attached to the parent, or
to another sub-application, no prefix coordination needed.
See [`examples/mounted_subapps_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/mounted_subapps_app.py).

### Callback hook

`callback` runs once per versioned router, right before `RouterVersioner` includes it in the
app, handy for logging every mount point or wiring metrics:

```python
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()
```

If `versionize()` raises (e.g. the callback itself throws), don't catch the exception and
retry: some versions may already be mounted, and this package makes no attempt to undo that.
`versionize()` normally runs at startup, so the app simply fails to start — fix the underlying
issue and start it again.

---

## Examples

| File | What it shows |
|---|---|
| [`semver_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/semver_app.py) | Full SemVer lifecycle: introduce, deprecate, remove, permanent deprecation, multi-method route with partial takeover |
| [`calver_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/calver_app.py) | Same lifecycle, CalVer date strings instead |
| [`semver_major_only_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/semver_major_only_app.py) | Major-only URLs (`/v1`, `/v2`) via `prefix_format` |
| [`webhook_versioning_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/webhook_versioning_app.py) | Per-version webhook definitions via `webhook_routers` |
| [`multi_router_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/multi_router_app.py) | Several routers versioned together under one instance |
| [`self_hosted_docs_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/self_hosted_docs_app.py) | Swagger UI and ReDoc served from local static assets |
| [`openapi_hook_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/openapi_hook_app.py) | Per-version OpenAPI schema edits via `openapi_hook` |
| [`mounted_subapps_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/mounted_subapps_app.py) | Independently versioned modules as separate `app.mount()` sub-applications |
| [`validation_override_integration_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/validation_override_integration_app.py) | Custom validation error status code via `fastapi-validation-override` and `openapi_hook` |

---

## Release Notes

[RELEASE_NOTES](https://github.com/mat81black/fastapi-router-versioning/blob/main/release-notes.md)

---

## License

[MIT](https://github.com/mat81black/fastapi-router-versioning/blob/main/LICENSE)
