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:
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 emitsDeprecation: true./api/v2/items— the replacement route.
The effective version for each route is resolved in this order:
versionpassed to the route decorator.versionpassed toapp.include_router(...).versionpassed to theAPIRouter(...)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:
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 Acceptableunless adefaultis provided. - A header value outside
allowedreturns406 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:
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:
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:
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:
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 emitsDeprecation: 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. |
Related examples¶
| Example | What it shows |
|---|---|
api_versioning.py |
Path, header, Accept, and deprecation versioning. |
routing.py |
Sub-routers, prefixes, and URL reversal. |