Metadata-Version: 2.5
Name: fastapi-router-versioning
Version: 1.1.0
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. `RouterVersioner` does it declaratively instead: annotate each route with the version it belongs to, and it generates the URL prefixes, the per-version OpenAPI schema, and the docs, 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
- **Deprecation headers**: opt-in `Deprecation`, `Sunset` and `Link` response headers on routes in their deprecation window
- **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.

Routes in their deprecation window can also emit `Deprecation`, `Sunset` and `Link` response
headers to clients; see [Deprecation headers](#deprecation-headers).

---

## `RouterVersioner` reference

`VersionT`, in the types below, is the version type of the format in use: `tuple[int, int]`
for SemVer, `str` for CalVer.

| 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 |
| `versions_route_path` | `str` | `/versions` | Path for that endpoint; must start with `/` |
| `include_versions_dashboard` | `bool` | `False` | Add an HTML page listing all active versions with links to their docs |
| `versions_dashboard_path` | `str` | `/dashboard` | Path for that page; must start with `/` |
| `versions_dashboard_hook` | `Callable[[list[dict], str], str] \| None` | `None` | Replace the built-in dashboard page; receives `(version_models, root_path)`, returns HTML |
| `deprecation_headers` | `bool` | `False` | Emit `Deprecation` / `Sunset` / `Link` headers on responses of routes in their deprecation window. See [Deprecation headers](#deprecation-headers) |
| `version_info` | `dict[VersionT, VersionInfo] \| None` | `None` | Per-version `VersionInfo(release_date=…, guide=…)` feeding those headers, and the `/versions` / dashboard listings. See [`VersionInfo` reference](#versioninfo-reference) |
| `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). Deprecation headers are configured on `RouterVersioner`, not here; see
[Deprecation headers](#deprecation-headers).

---

## `VersionInfo` reference

```python
VersionInfo(release_date=None, guide=None)
```

Calendar metadata about one version, passed to `RouterVersioner` as
`version_info={version: VersionInfo(...)}`. It never affects routing. Both fields feed the
deprecation headers, and only when `deprecation_headers=True`; see
[Deprecation headers](#deprecation-headers). `guide` is also listed for the version on
`GET /versions` and the dashboard, which don't need that flag.

| Field | Type | Default | Description |
|---|---|---|---|
| `release_date` | `date \| datetime \| None` | `None` | The date this version goes live. Becomes the `Deprecation` header on routes whose `deprecate_in` is this version, and the `Sunset` header on routes whose `remove_in` is this version |
| `guide` | `str \| None` | `None` | URL of this version's upgrade guide. Becomes the `Link: <guide>; rel="deprecation"` header on routes deprecated at this version, and is listed as `guide_url` for the version on `GET /versions` and the dashboard |

`VersionInfo` is frozen, and both fields are optional.

`release_date` accepts a `datetime` as well as a `date`, and the two are read differently: a
`date` is pinned to midnight UTC, an aware `datetime` keeps its own offset, and a naive
`datetime` is read as UTC. Both headers carry a point in time, so passing a naive `datetime`
from a non-UTC local clock shifts the value clients see.

---

## Advanced options

### Deprecation headers

By default the lifecycle is docs-only: a deprecated route is flagged in its per-version
OpenAPI, but a client calling it gets no signal. Set `deprecation_headers=True` and
`RouterVersioner` adds the `Deprecation`, `Sunset` and `Link` headers to every response of a
route in its deprecation window; `version_info` (one `VersionInfo` per version) supplies the
dates and guide URLs. Header values are computed once, at `versionize()` time. The only
per-request work is prefixing the `successor-version` link with the request's `root_path`,
exactly as the built-in `/docs` and `/openapi.json` routes do.

```python
from datetime import date
from fastapi_router_versioning import RouterVersioner, VersionInfo

RouterVersioner(
    app=app,
    routers=router,
    deprecation_headers=True,
    version_info={
        (1, 0): VersionInfo(release_date=date(2024, 1, 15)),
        (2, 0): VersionInfo(
            release_date=date(2025, 3, 1),
            guide="https://api.example.com/docs/upgrade/v2",
        ),
        (3, 0): VersionInfo(release_date=date(2026, 1, 1)),
    },
).versionize()
```

| Header | Source | Emitted when |
|---|---|---|
| `Deprecation: @<unix-seconds>` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)) | `version_info[deprecate_in].release_date` | that version has a date |
| `Sunset: <HTTP-date>` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) | `version_info[remove_in].release_date` | `remove_in` is set and that version has a date |
| `Link: …; rel="successor-version"` ([RFC 5829](https://www.rfc-editor.org/rfc/rfc5829)) | the next mounted version still serving the same `(path, method)`, `root_path`-prefixed | such a version exists |
| `Link: …; rel="deprecation"` (RFC 9745) | `version_info[deprecate_in].guide` | that version has a guide URL |

Both `VersionInfo` fields are optional; a version missing from the map, or a field left
`None`, just yields nothing for that part, with no warning. With `deprecation_headers=True`
and no `version_info` at all, only the `successor-version` link is emitted (it needs no
config). `versionize()` raises `ValueError` if a route's `remove_in` date precedes its
`deprecate_in` date (when both are dated): RFC 9745 forbids a `Sunset` earlier than the
`Deprecation`.

This adds nothing to `@api_version`: the lifecycle stays on the route, the dates stay on the
versioner. `Deprecation` and `Sunset` are only set if the response doesn't already carry them:
a route that sets its own keeps it. The `Link` field is sent alongside any the route already
emits (RFC 8288: multiple `Link` fields combine). See [`deprecation_headers_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/deprecation_headers_app.py).

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

`/latest` gets its own docs pages like any other version, but it isn't listed in
`GET /versions` or the dashboard: those list versions, and `/latest` is a pointer to
one of them.

### 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"
    }
  ]
}
```

Pass `versions_route_path="/api-versions"` (any path starting with `/`) to mount the endpoint
somewhere other than `/versions`.

A version whose `VersionInfo` sets `guide` also carries a `"guide_url"` in its entry, sent as
given (an external URL, with no `root_path` prefix).

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)). That single
endpoint is mounted by the first of those instances, which also fixes its path: a different
`versions_route_path` passed by a later instance has no effect.

Set `include_versions_dashboard=True` for an HTML counterpart: a page (at `/dashboard` by
default, moved with `versions_dashboard_path`) headed by the app's `title` and `version` and
listing the same versions with links to each one's Swagger, ReDoc, `openapi.json`, and its
`guide` when `version_info` gives one. It aggregates across instances and follows the same
first-instance-wins rule, works whether or not `include_versions_route` is also on, and is
kept out of the OpenAPI schema. The built-in page is plain; pass
`versions_dashboard_hook(version_models, root_path) -> str` to render your own instead (it
isn't handed the app metadata, so read `app.title` / `app.version` off your own app reference
if you want them).

### 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`.

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 (see
[Reverse proxy and sub-application mounting](#reverse-proxy-and-sub-application-mounting)).

### 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.

### 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` |
| [`deprecation_headers_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/deprecation_headers_app.py) | `Deprecation`, `Sunset` and `Link` response headers via `deprecation_headers` and `version_info` |
| [`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` |
| [`versions_dashboard_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/versions_dashboard_app.py) | `GET /versions` JSON and the `GET /dashboard` HTML page side by side |
| [`versions_dashboard_hook_app.py`](https://github.com/mat81black/fastapi-router-versioning/blob/main/examples/versions_dashboard_hook_app.py) | Custom dashboard via `versions_dashboard_hook`: Jinja template with a separate stylesheet file |
| [`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)
