Skip to content

API Versioning

A pack does not hunt the same ground forever. As the territory changes, the old trails must stay passable while the pack learns new ones. VelociPy's versioning tools let you expose multiple API versions side by side - through URL paths, headers, or content negotiation - without tearing up the routes that came before.

Run the examples

See examples/api_versioning.py for a runnable demo of path, header, Accept, and deprecation versioning.


URL path versioning

Add a version segment to an APIRouter. The version is appended to the router prefix when the router is included in an application.

from velocipy import APIRouter, VelociPy

app = VelociPy()
items = APIRouter(prefix="/api", version="v1", tags=["items"])

@items.get("/items")
def list_items():
    return {"version": "v1", "items": []}

app.include_router(items)

The route above is mounted at /api/v1/items. You can override the version at inclusion time:

app.include_router(items, version="v2")  # mounts under /api/v2/items

Version segments are validated when the router is created: they must not contain /, \, .., whitespace, or control characters. This prevents path traversal or injection through a version value.

Duplicate route conflicts

Registering the same path and method twice raises RouteConflictError. It subclasses ValueError, so existing except ValueError handlers still work:

from velocipy import RouteConflictError, VelociPy

app = VelociPy()

@app.get("/items")
def list_items():
    return {"items": []}

try:
    @app.get("/items")
    def list_items_again():
        return {"items": []}
except RouteConflictError as exc:
    print(exc)  # Route conflict: GET /items

Per-route versioning

You can also place the version on individual routes. The route-level version overrides any include-time or router-level version for that route, so the same router can register the same relative path under different versions.

from velocipy import APIRouter, VelociPy

app = VelociPy()
items = APIRouter(prefix="/api", tags=["items"])

@items.get("/items", version="v1", deprecated=True)
def list_items_v1():
    return {"version": "v1", "items": []}

@items.get("/items", version="v2")
def list_items_v2():
    return {"version": "v2", "items": []}

app.include_router(items)

This produces two distinct paths:

  • /api/v1/items — marked deprecated and emits Deprecation: true.
  • /api/v2/items — the replacement route.

The effective version for each route is resolved in this order:

  1. version passed to the route decorator.
  2. version passed to app.include_router(...).
  3. version passed to the APIRouter(...) constructor.

Route-level version segments use the same validation as router-level versions.

You can also use version= directly on application routes. The route is mounted under /{version}{path} and tagged in OpenAPI just like a versioned router route:

@app.get("/items", version="v1")
def list_items():
    return {"version": "v1", "items": []}

This mounts at /v1/items.

Because the version becomes part of the URL path, path-based features such as the response cache and path() rate-limit identifiers are version-aware by default. /v1/items and /v2/items are cached and limited independently.

WebSocket versioning

WebSocket routes support the same version=, deprecated=, and deprecation_description= parameters as HTTP routes:

from velocipy import APIRouter, VelociPy

app = VelociPy()
items = APIRouter(prefix="/api")

@items.websocket("/ws", version="v1", deprecated=True)
async def legacy_ws(ws):
    await ws.accept()

@items.websocket("/ws", version="v2")
async def current_ws(ws):
    await ws.accept()

app.include_router(items)

The WebSocket endpoints above are reachable at /api/v1/ws and /api/v2/ws. When a deprecated WebSocket route is accepted over ASGI, VelociPy includes Deprecation: true (and Sunset when the description is an HTTP date) in the handshake response headers.

Under RSGI the versioned paths and the deprecated flag in OpenAPI still work, but the accept() protocol has no way to attach extra handshake headers, so Deprecation/Sunset headers are not emitted during the WebSocket upgrade.


Header versioning

APIVersion.from_header creates a dependency that reads X-API-Version (or any custom header) and returns the version string.

from velocipy import Depends, VelociPy
from velocipy.features.versioning import APIVersion

app = VelociPy()

@app.get("/items")
def list_items(
    version: str = Depends(APIVersion.from_header(allowed={"v1", "v2"})),
):
    return {"version": version}

Behavior:

  • A missing header returns 406 Not Acceptable unless a default is provided.
  • A header value outside allowed returns 406 Not Acceptable.
  • Newline, null-byte, control-character, and path-separator values are rejected to prevent header injection and traversal.

Use default="v1" to make the version optional:

version: str = Depends(APIVersion.from_header(default="v1"))

When caching responses for header-versioned routes, include the header in the cache policy's vary so different versions do not share a cached response:

@app.get("/items", cache=cache.policy(ttl=60, vary=("X-API-Version",)))

Use a header-based rate-limit identifier such as header("X-API-Version") if you want per-version rate limits.


Content negotiation (Accept header)

APIVersion.from_accept parses the Accept header using a configurable vendor media type pattern.

@app.get("/items")
def list_items(
    version: str = Depends(
        APIVersion.from_accept("application/vnd.example.{version}+json")
    ),
):
    return {"version": version}

Clients request a version with:

Accept: application/vnd.example.v2+json

Quality parameters such as ;q=0.9 are ignored during matching. If no listed media type matches, the dependency returns 406 Not Acceptable or falls back to default when one is configured.

When caching Accept-versioned responses, add Accept to the cache policy's vary so that different negotiated versions are cached separately:

@app.get("/items", cache=cache.policy(ttl=60, vary=("Accept",)))

Composed versioning

APIVersion.from_request tries the header first, then Accept, then the default.

@app.get("/items")
def list_items(
    version: str = Depends(
        APIVersion.from_request(
            header_name="X-API-Version",
            accept_pattern="application/vnd.example.{version}+json",
            default="v1",
            allowed={"v1", "v2"},
        )
    ),
):
    return {"version": version}

Deprecation

Mark individual routes or entire routers as deprecated. Deprecated routes emit a Deprecation: true response header and are flagged as deprecated in the OpenAPI documentation.

Per-route deprecation

@app.get(
    "/legacy",
    deprecated=True,
    deprecation_description="Use /api/v1/items instead.",
)
def legacy_items():
    return {"note": "deprecated"}

Router-level deprecation

items_v1 = APIRouter(
    prefix="/api",
    version="v1",
    deprecated=True,
    deprecation_description="Use /api/v2 instead.",
)

If the deprecation description is a valid HTTP-date string, VelociPy also sends a Sunset header:

@app.get(
    "/legacy",
    deprecated=True,
    deprecation_description="Thu, 01 Jan 2026 00:00:00 GMT",
)
def legacy_items():
    ...

Deprecating one version and adding the next

Because the version is part of the router prefix, you can keep a deprecated v1 router and add a v2 router that registers the same relative paths.

v1 = APIRouter(prefix="/api", version="v1", deprecated=True)
v2 = APIRouter(prefix="/api", version="v2")

@v1.get("/items")
def list_items_v1():
    return {"version": "v1"}

@v2.get("/items")
def list_items_v2():
    return {"version": "v2"}

app.include_router(v1)
app.include_router(v2)

This produces two distinct paths:

  • /api/v1/items — marked deprecated and emits Deprecation: true.
  • /api/v2/items — the replacement route.

Security notes

The versioning helpers validate every version token:

  • Path-separator characters (/, \) are rejected.
  • Parent-directory sequences (..) are rejected.
  • Null bytes and control characters are rejected.
  • Header values containing newlines are rejected to prevent response splitting.
  • Accept-header parsing uses a strict media-type regex.
  • Version tokens are length-limited to avoid denial-of-service from oversized headers.

Version strings are never used for file-system, shell, or SQL operations inside VelociPy. They are treated as opaque route or dependency identifiers.


At a glance

Approach How
APIRouter(prefix="/api", version="v1") Adds a version segment to every route in the router.
app.include_router(router, version="v2") Overrides the router version at inclusion time.
@app.get("/items", version="v1") Sets the version on a single route.
APIVersion.from_header(...) Reads the version from a request header.
APIVersion.from_accept(...) Parses the version from the Accept header.
APIVersion.from_request(...) Tries header, then Accept, then a default.
deprecated=True / deprecation_description=... Marks routes or routers deprecated and emits Deprecation/Sunset headers.

Example What it shows
api_versioning.py Path, header, Accept, and deprecation versioning.
routing.py Sub-routers, prefixes, and URL reversal.
  • Routing — sub-routers, prefixes, and named routes.
  • OpenAPI — how deprecated routes appear in the generated docs.