Skip to content

Endpoint migrations

Note that the endpoint(...) constructor contains a second argument that describes the methods of the endpoints you would like to edit. If you have two routes for a single endpoint and you include both of their methods in the instructions, both of them will be changed.

Defining endpoints that did not exist for new versions

If you have an endpoint in an old version but want to delete it in a new version, define it as usual with all your other endpoints but mark it as deleted...

@router.only_exists_in_older_versions
@router.get("/users/{user_id}")
async def my_old_endpoint(): ...

and then define it as existing in one of the older versions:

from cadwyn import VersionChange, endpoint


class RemoveGetUserByIdEndpoint(VersionChange):
    """The 'GET /users/{user_id}' endpoint has been removed because user
    profiles are now retrieved through the users collection.
    """

    instructions_to_migrate_to_previous_version = (
        endpoint("/users/{user_id}", ["GET"]).existed,
    )

Defining endpoints that did not exist for old versions

If you have an endpoint in a new version that should not exist in older versions, define it as usual and then mark it as "nonexistent" in old versions. Note that this approach is not recommended for adding new endpoints.

from cadwyn import VersionChange, endpoint


class AddGetCompanyByIdEndpoint(VersionChange):
    """Clients can now retrieve an individual company with
    'GET /companies/{company_id}' instead of filtering the company list.
    """

    instructions_to_migrate_to_previous_version = (
        endpoint("/companies/{company_id}", ["GET"]).didnt_exist,
    )

Changing endpoint attributes

If you want to change an endpoint attribute (like description) in a new version, you can return its value in all older versions like this:

from cadwyn import VersionChange, endpoint


class ChangeGetUserByIdEndpointDescription(VersionChange):
    """The 'GET /users/{user_id}' documentation now clarifies the returned
    user data so clients can interpret the response correctly.
    """

    instructions_to_migrate_to_previous_version = (
        endpoint("/users/{user_id}", ["GET"]).had(
            description="My old description",
        ),
    )

However, a migration is required only if it is a breaking change for your users.

Warning: dependency changes

Note that changing FastAPI endpoint dependencies is only going to affect the initial validation. Cadwyn will run your altered dependencies for each request to the endpoint but ultimately your endpoint code is always going to use the HEAD version of your dependencies. So be careful.

Also note that if some of your dependencies are added at app/router level, they are going to be overwritten by the instructions above. Most of the time it is rather safe, however, as all the necessary dependencies will still run on HEAD version.

WebSocket endpoint migrations

Use ["WEBSOCKET"] to select a FastAPI WebSocket route defined with @router.websocket(). It supports .didnt_exist, .existed, and .had(path=..., name=..., dependencies=...). Mark sockets that only exist in older versions with @router.only_exists_in_older_versions, just like HTTP endpoints.

from cadwyn import (
    Cadwyn,
    Version,
    VersionBundle,
    VersionChange,
    VersionedAPIRouter,
    endpoint,
)
from fastapi import Depends, Header, WebSocket


async def current_token(token: str = Header()):
    return token


async def legacy_token(legacy_token: str = Header()):
    return legacy_token


router = VersionedAPIRouter()


@router.websocket(
    "/ws/{room}", name="room", dependencies=[Depends(current_token)]
)
async def room_socket(websocket: WebSocket, room: str):
    await websocket.accept()
    await websocket.send_json({"room": room})
    await websocket.close()


@router.websocket("/notifications")
async def notifications(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_text("notification")
    await websocket.close()


@router.only_exists_in_older_versions
@router.websocket("/legacy")
async def legacy_socket(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_text("legacy")
    await websocket.close()


class UpdateSockets(VersionChange):
    description = "Move chat connections and their token header; replace the legacy socket."
    instructions_to_migrate_to_previous_version = (
        endpoint("/ws/{room}", ["WEBSOCKET"]).had(
            path="/chat/{room}",
            name="chat",
            dependencies=[Depends(legacy_token)],
        ),
        endpoint("/notifications", ["WEBSOCKET"]).didnt_exist,
        endpoint("/legacy", ["WEBSOCKET"]).existed,
    )


app = Cadwyn(
    versions=VersionBundle(
        Version("2024-02-01", UpdateSockets), Version("2024-01-01")
    )
)
app.generate_and_include_versioned_routers(router)

In this example, clients of 2024-02-01 connect to /ws/general with the token header. Clients of 2024-01-01 connect to /chat/general with legacy-token instead. The older version also keeps /legacy and has no /notifications socket. Both clients pass their API version in the x-api-version header; path versioning and constant defaults also work.

Sockets run only the selected version's dependencies

An HTTP endpoint of an older version runs that version's dependencies and then the latest version's dependencies again. A WebSocket doesn't: with .had(dependencies=...), the older version's socket runs only the dependencies you list there, so add every dependency it still needs, such as authentication. This includes dependency overrides and cleanup.

.had(dependencies=...) replaces the route's dependency list, including dependencies inherited from included routers. Dependencies declared in the endpoint signature still run. A path change must keep the same path parameter names. Use func_name to distinguish sockets with the same path, as in the endpoint duplicates example below.

WEBSOCKET must be the only method in its selector, and .had() accepts only path, name, and dependencies for sockets. Other attributes and mixed HTTP/WebSocket selectors raise an error when the instruction is defined. Request and response data converters remain HTTP-only.

WebSockets stay out of OpenAPI. Cadwyn does not apply schema or message migrations to WebSocket connections; handle message formats in your endpoint code. These instructions support FastAPI APIWebSocketRoute routes only: plain Starlette WebSocketRoutes (@router.websocket_route()), plain Starlette Routes, Mount, and static files are outside their scope.

Dealing with endpoint duplicates

Sometimes, when making advanced changes between versions, you need to rewrite your endpoint function entirely. So essentially you would have the following structure:

from typing import Annotated

from cadwyn import VersionedAPIRouter
from fastapi import Header
from fastapi.params import Param

router = VersionedAPIRouter()


@router.only_exists_in_older_versions
@router.get("/users")
def get_users_by_name_before_starting_to_use_params(
    user_name: Annotated[str, Header()],
):
    """Some code that references user_name"""


@router.get("/users")
def get_users_by_name(user_name: Annotated[str, Param()]):
    """Some code that references user_name"""

As you can see in the code example above, the two functions have the same parameters and path decorators. And if you have many versions, you may have even more functions like these two. The recommended way to instruct Cadwyn to restore one of them and delete the other is the following:

from cadwyn import VersionChange, endpoint


class UseParamsInsteadOfHeadersForUserNameFiltering(VersionChange):
    """'GET /users' now accepts the user-name filter as a query parameter
    instead of a header, following standard HTTP filtering conventions.
    """

    instructions_to_migrate_to_previous_version = (
        # Specify the name; otherwise you will encounter an exception due to
        # having two identical endpoints with the same parameters and path decorators
        endpoint(
            "/users",
            ["GET"],
            func_name="get_users_by_name_before_starting_to_use_params",
        ).existed,
        # Also specify the name here because, following the instructions above,
        # we now have two existing endpoints
        endpoint("/users", ["GET"], func_name="get_users_by_name").didnt_exist,
    )
Using a more specific func_name allows to distinguish between different functions that affect the same routes.