API version parameter¶
Cadwyn adds another routing layer to FastAPI by default: by a version parameter. This means that before FastAPI selects the correct route for the request, Cadwyn decides which version of the route will be used based on the version parameter. Feel free to look at the example app with URL path version prefixes and arbitrary strings as versions here.
API version location¶
A version parameter can be passed in two different ways:
- as a custom header
- as a URL path parameter
Cadwyn uses the following defaults:
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
api_version_location="custom_header",
api_version_parameter_name="x-api-version",
versions=VersionBundle(Version("2022-01-02")),
)
but you can change the header name if you want to:
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
api_version_location="custom_header",
api_version_parameter_name="MY-GREAT-HEADER",
versions=VersionBundle(Version("2022-01-02")),
)
or you can use a path parameter:
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
api_version_location="path",
api_version_parameter_name="api_version",
versions=VersionBundle(Version("2022-01-02")),
)
With a path parameter, add it to the paths of your versioned routes, e.g. @router.get("/{api_version}/users"). Cadwyn reads the version only from that parameter's position in the matched route, so other path parameters can hold version-like values: with /{tenant}/{api_version}/users, a request to /2024-02-01/2024-01-01/users uses version 2024-01-01. Path versions must match one of your versions exactly, so path versioning doesn't waterfall: a request with any other value gets a 404. If no versioned route matches the URL at all, Cadwyn uses the first of your versions that appears anywhere in the URL, so the 404 or 405 response still has that version. If different versions put the API version parameter in different positions and a URL matches more than one of them, Cadwyn raises a CadwynError.
API version format¶
A version parameter can be formatted in two different ways:
- as an ISO date
- as an arbitrary string
Cadwyn uses the following default:
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
api_version_format="date",
versions=VersionBundle(Version("2022-01-02")),
)
In the example above only dates are accepted as valid versions.
You can also use an arbitrary string:
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
api_version_format="string",
versions=VersionBundle(
Version("v2"),
Version("anything_can_be_a_version"),
Version("v1"),
),
)
In the example above any arbitrary string is accepted as a valid version. Cadwyn does not sort them, Cadwyn assumes that their actual order matches the order of the versions in the VersionBundle.
API version waterfalling¶
For historical reasons, date-based routing with a header or a default version also supports waterfalling the requests to the closest earlier version of the API if the request date parameter does not match any of the versions exactly.
If the app has two versions: 2022-01-02 and 2022-01-05, and the request date parameter is 2022-01-03, then the request will be routed to the 2022-01-02 version, as it is the closest version, but lower than the request date parameter.
An exact match is always preferred to a partial match and a request is never matched to the higher-versioned route.
The routing is implemented like this because Cadwyn was born in a microservice architecture and it is extremely convenient to have waterfalling there. Assume that you have two Cadwyn services: Payables and Receivables, each defining its own API versions. Payables service might have ten versions while Receivables service might have only two versions because it did not need as many breaking changes. If a client requests a version that does not exist in Receivables, Cadwyn will waterfall to some earlier version, making Receivables behavior consistent even if API keeps getting new versions.
Requests without an API version¶
Cadwyn routes versioned endpoints by the API version, so a request without it gets a 404. That's why the OpenAPI of each version documents the version header as required unless you set a default API version. Some clients cannot send the version header. For example, Swagger UI's "Authorize" button for OAuth2PasswordBearer(tokenUrl=...) sends the token request to tokenUrl without any custom headers. You have two options for such endpoints.
Keep the endpoint unversioned¶
Define the endpoint on a regular fastapi.APIRouter and include it with app.include_router(), either before or after generate_and_include_versioned_routers(). Cadwyn does not version such routes:
from typing import Annotated
from cadwyn import Cadwyn, Version, VersionBundle, VersionedAPIRouter
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
# A regular FastAPI router: Cadwyn does not version its routes
auth_router = APIRouter()
@auth_router.post("/token")
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
# Check the credentials and issue a real token here
return {"access_token": form_data.username, "token_type": "bearer"}
router = VersionedAPIRouter()
@router.get("/users/me")
async def read_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
return {"token": token}
app = Cadwyn(versions=VersionBundle(Version("2000-01-01")))
app.include_router(auth_router)
app.generate_and_include_versioned_routers(router)
Unversioned routes are only available to requests without the version header, so all clients have to call this endpoint without it. Cadwyn does not migrate unversioned endpoints, so if you ever change this endpoint, keep it compatible with your older clients and cover that with tests.
Set a default API version¶
Pass api_version_default_value to Cadwyn to route the requests without the version header to a specific version:
from typing import Annotated
from cadwyn import Cadwyn, Version, VersionBundle, VersionedAPIRouter
from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
router = VersionedAPIRouter()
@router.post("/token")
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
# Check the credentials and issue a real token here
return {"access_token": form_data.username, "token_type": "bearer"}
@router.get("/users/me")
async def read_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
return {"token": token}
app = Cadwyn(
versions=VersionBundle(Version("2000-01-01")),
# Requests without the version header get this version
api_version_default_value="2000-01-01",
)
app.generate_and_include_versioned_routers(router)
Note that this applies to every request without the version header, not only to the token endpoint. api_version_default_value can also be an async function that takes the Request and returns the version.
WebSocket connections¶
WebSocket routes included with generate_and_include_versioned_routers() pick their version with the same rules as HTTP requests: from the configured header or path, and otherwise from a constant api_version_default_value. A date version from a header or default that has no exact match uses the closest earlier version. Path versioning accepts only the versions in your VersionBundle, so a socket at /2024-01-15/ws is rejected just as GET /2024-01-15/users returns a 404.
from cadwyn import Cadwyn, Version, VersionBundle, VersionedAPIRouter
from fastapi import WebSocket
versions = VersionBundle(Version("2024-02-01"), Version("2024-01-01"))
app = Cadwyn(versions=versions)
router = VersionedAPIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
await websocket.send_json({"version": versions.api_version_var.get()})
await websocket.close()
app.generate_and_include_versioned_routers(router)
The selected version is available in versions.api_version_var before dependencies run, throughout the connection and dependency cleanup. Cadwyn restores the previous context when the connection ends. Missing, invalid, and unmatched versions close the connection before acceptance with code 1008 and an API version reason. A callable api_version_default_value requires an HTTP Request: for WebSockets, send the version explicitly or use a constant default.
A custom versioning_middleware_class does not run for WebSockets because it is a BaseHTTPMiddleware, which handles only HTTP requests. Sockets use the header or path, the default, or a version that your own ASGI middleware sets in api_version_var before the connection reaches Cadwyn's router. Note that browser WebSocket clients can't send custom headers, so for them use path versioning, a constant default, or such an ASGI middleware.
Routes included with app.include_router() keep working as unversioned WebSockets, even when a version header or default is present. A version preselected in api_version_var retains its routing behavior. WebSockets stay out of OpenAPI. See WebSocket endpoint migrations for changing their routes and dependencies between versions.
API version parameter title and description¶
You can pass a title and/or a description to the Cadwyn() constructor. It is equivalent to passing title and description to fastapi.Path() or fastapi.Header() constructors.
from cadwyn import Cadwyn, Version, VersionBundle
app = Cadwyn(
versions=VersionBundle(Version("2022-01-02")),
api_version_title="My Great API version parameter",
api_version_description="Description of my great API version parameter",
)
API version context variables¶
Cadwyn automatically converts your data to the correct version and has "version checks" when dealing with side effects as described in the section above. It can only do so using a special context variable that stores the current API version.
You can also pass a different compatible contextvar to your cadwyn.VersionBundle() constructor.