Change field type in schemas¶
Change the type incompatibly¶
If your data had a type int and you want to change it to a str in a new version, then your data from the new version can easily break the responses of the old versions, thus making it data versioning, not API versioning -- as you are versioning the fundamental structures the user is operating on instead of just the API.
Expand the type¶
Suppose your clients could choose a role for your users. Originally, it was only possible to choose admin or regular but you would like to expand it to moderator which has all the permissions of an admin except assigning other admins.
This is not a breaking change in terms of requests but it can be a breaking change in terms of responses. So if you do consider it a breaking change in terms of responses, do the following:
- Add
moderatorvalue intousers.UserRoleEnum -
Add the following migration to
versions.v2001_01_01:from cadwyn import ( ResponseInfo, VersionChange, convert_response_to_previous_version_for, enum, ) from users import UserResource, UserRoleEnum class AddModeratorRoleToUser(VersionChange): description = ( "Add 'moderator' role to users that represents an admin " "that cannot create or remove other admins. This provides " "finer-grained control over permissions." ) instructions_to_migrate_to_previous_version = ( enum(UserRoleEnum).didnt_have("moderator"), ) @convert_response_to_previous_version_for(UserResource) def change_moderator_to_regular(response: ResponseInfo): if response.body["role"] == "moderator": response.body["role"] = "regular"
You convert moderators to regulars in older versions because it is a safer choice for your users.
Why enum expansion is a breaking change for responses¶
Suppose your schema contained a list that contains euros and/or dollars. To unmarshal the JSON, Cadwyn takes the JSON string and tries to convert it to a list of euros and/or dollars. If there appears Georgian lari in the list, Cadwyn will fail to unmarshal such a list, which makes adding an enum value a breaking change for a list of items.
If an API client expects Array<Euro | Dollar>, then Array<Euro> and Array<Dollar> would both be considered compatible responses but Array<Euro | Dollar | Lari> would not. That happens because Array<Euro | Dollar | Lari> is a not a subtype of Array<Euro | Dollar> while Array<Euro> is.
In a sense, extending an enum that has USD with USD | EUR is equivalent to turning an int field into an int | str field, which is a breaking change. Hence extending an enum is often a breaking change and thus you might not need to solve this problem at all.
Additional resources:
- https://github.com/OAI/OpenAPI-Specification/issues/1552
- https://users.rust-lang.org/t/solved-is-adding-an-enum-variant-a-breaking-change/26721/5
- https://github.com/graphql/graphql-js/issues/968
In these sections, we'll be working with Cadwyn's user response model: users.UserResource. Note that the main question here is "Will I be able to serialize this change to any of my versions?" as any change to responses can make them incompatible with the data in your database.
Narrow the type¶
Suppose that previously users could specify their date of birth as a datetime instead of a date. You are planning to change that. You can solve this by making it a datetime in HEAD version, converting it to date in latest version, and then making it a datetime again in the old versions. So whenever you receive a request in an old version, it will get converted to HEAD version where it is a datetime. And whenever you receive a request in latest version, it will also be converted to HEAD where date will simply be casted to datetime with time = 00:00:00.
- Continue storing
date_of_birthas a datetime in your database to avoid breaking any old behavior -
Add the following migration to
versions.v2001_01_01which will turndate_of_birthinto a date in 2001_01_01. Note how the validator is used to make sure thatdate_of_birthis converted to date in the latest version. It is only necessary in Pydantic 2 because it has no implicit casting from datetime to date. Note also how strings are used for types: this is not always necessary; it just allows you to control how Cadwyn is going to render your types. Most of the time you won't need to use strings for types.import datetime from cadwyn import VersionChange, schema from pydantic import validator from users import BaseUser @field_validator("date_of_birth", mode="before") def convert_date_of_birth_to_date(cls, v: datetime.date | datetime.datetime): if isinstance(v, datetime.datetime): return v.date() return v class ChangeDateOfBirthToDateInUserInLatest(VersionChange): description = ( "Change 'BaseUser.date_of_birth' field type to datetime in HEAD " "to support versions and data before 2001-01-01. " ) instructions_to_migrate_to_previous_version = ( schema(BaseUser).field("date_of_birth").had(type=datetime.date), # This step is only necessary in Pydantic 2 because datetime # won't be converted to date automatically. schema(BaseUser).validator(convert_date_of_birth_to_date).existed, ) -
Add the following version change to
versions.v2001_01_01(right under the version change above) which will make sure thatdate_of_birthis a datetime in 2000_01_01:class ChangeDateOfBirthToDateInUser(VersionChange): description = ( "Change 'User.date_of_birth' field type to date instead of " "a datetime because storing the exact time is unnecessary." ) instructions_to_migrate_to_previous_version = ( schema(BaseUser).field("date_of_birth").had(type=datetime.datetime), schema(BaseUser).validator(convert_date_of_birth_to_date).didnt_exist, ) -
Add both migrations into the VersionBundle:
from cadwyn import Version, VersionBundle, HeadVersion version_bundle = VersionBundle( HeadVersion(ChangeDateOfBirthToDateInUserInLatest), Version("2001-01-01", ChangeDateOfBirthToDateInUser), Version("2000-01-01"), )
The process above is a bit complex, so let us break it down:
date_of_birthfield is a datetime in HEAD, a date in 2001, and a datetime again in 2000.- You need a way to keep the 2000 behavior making it unavailable for users in 2001. Cadwyn always converts all requests to the HEAD version so:
- When user creation requests are received from 2001, they are converted directly to HEAD, and pydantic casts date to datetime without any issue
- When user get requests are received from 2001, they are converted directly from HEAD to latest, and the validator casts datetime to date (note that pydantic 1 would be able to do it even without a validator)
- When user creation requests are received from 2000, they are converted directly to HEAD. They have the same type for
date_of_birth, so Cadwyn easily processes them - When user get requests are received from 2000, they are converted directly from HEAD to 2000. They have the same type for
date_of_birth, so Cadwyn easily processes them
All of these interactions are done internally by Cadwyn. As you see, the process is more than straightforward: requests are converted to HEAD, and responses are converted from HEAD.
Thus, you have kept the old behavior, added new constrained behavior, and minimized the impact on your business logic as business logic simply doesn't know that date_of_birth in requests is ever a date and that date_of_birth in responses is ever a date. All of this information is hidden in your migration.
Please note that unlike schemas, routes, and business logic, the migrations written above will likely never need to change because they describe the fundamental differences between the API versions, and these differences cannot be changed in the future because that would defeat the purpose of API versioning. This makes migrations effectively immutable and consequently very cheap to support.