Schemas & Validation#
Note
Restly says schema for a Pydantic class and model for a SQLAlchemy
class. A User model is the database table. UserSchema, the view’s schema,
is the public API.
A view has several schemas. The main one is the view’s
schema, a Pydantic class
that lists the fields of the resource. When you register the view,
Restly builds the other schemas from it, one for each use:
Class |
Used for |
What Restly leaves out of the view’s schema |
|---|---|---|
response class, |
everything that goes out |
the |
create body, |
the body of |
the |
update body, |
the body of |
the |
Each of these classes is a subclass of the view’s schema. So each one keeps
the field types, the aliases and the validators of the view’s schema. Two
markers decide which class keeps a field: fr.ReadOnly keeps it out of the request bodies, and
fr.WriteOnly keeps it out of the
responses. The filters and sort keys of the list route come from the
response class too; Filtering, Sorting & Pagination
covers them.
You can set each class on the view yourself, as
schema_response,
schema_create or
schema_update.
Restly then uses your class as you wrote it. A view without a
schema gets one that
Restly generates from the model.
Find the section for your goal:
Goal |
Tool |
|---|---|
A public API that differs from the table |
|
An input field that responses never show |
|
A field that the server owns and clients cannot set |
|
A field set on create and frozen afterwards |
|
A rule that a value must follow |
|
Times from clients in other time zones |
|
Other field names in the JSON |
|
A response that differs from the input |
|
Clear type names in OpenAPI and generated clients |
|
A custom route that returns the view’s classes |
|
A foreign key, or a relationship as an id or an object |
Generate the schema or write it#
Without a schema, Restly generates the view’s schema from the model. This is enough when the table is already the API you want:
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
The generated schema has one field for each column, and no relationships.
Restly marks id, created_at, updated_at and each column with
init=False as ReadOnly; see
How Restly generates a schema for the details.
Write the schema yourself when the API differs from the table: other field names, fields that only go one way, references to other rows, or rules for the values. Write it also when the field names must stay the same while the table changes. Set it on the view:
class UserSchema(fr.IDSchema):
name: str
email: str
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
Restly writes each field of a request body to the model attribute with the same Python name. So name a field after its column, and use an alias when the JSON needs another name.
Choose a base class#
Restly has three base classes for a schema:
fr.IDSchemaadds a read-onlyidfield, asid: fr.ReadOnly[Any]. Most schemas use it. Theidis in the responses, and the create and update bodies leave it out.fr.BaseSchemais the class underIDSchema, without theid. Use it to declare every field yourself,idincluded. Mark theidReadOnlyif clients must not send it.fr.TimestampsSchemaMixinadds read-onlycreated_atandupdated_atfields, in UTC. Put it before the base class.
class UserSchema(fr.TimestampsSchemaMixin, fr.IDSchema):
name: str
email: str
Details
BaseSchema is a Pydantic BaseModel with from_attributes=True, so it can
read the attributes of an ORM object. It writes a NaN or an infinity in a
response as text; see Keep NaN and infinity. It also
checks that the markers are used in the right place; see
the next section.
A request body that you set on the view, such as schema_create, can also be
a plain pydantic.BaseModel. Its fields can still use ReadOnly,
WriteOnly and fr.MustExist.
fr.IDSchema[User], with the model as a type parameter, gives the schema the
model’s type; Type Annotations explains when that helps.
As the type of a field, fr.IDSchema[User] is a reference to another row;
see Relationships.
Hide a field from requests or from responses#
fr.ReadOnly[T] marks a field that
only goes out. The create and update bodies leave it out, so a client cannot
set it. A value that a client sends for it is ignored:
class UserSchema(fr.IDSchema):
name: str
created_by_id: fr.ReadOnly[int]
The server sets such a value. Set it on the model, with a column default, so that every write path sets it; see Stamp the fields the server owns.
fr.WriteOnly[T] marks a field that
only comes in. The create and update bodies accept it, and no response shows
it:
class UserSchema(fr.IDSchema):
email: str
password: fr.WriteOnly[str]
Pydantic’s secret types, such as SecretStr, work in a schema too. A write
stores the secret, and a response shows **********, as Pydantic does. So
a client sees that the field has a value, but not the value. Like a
WriteOnly field, such a field gets no filter and is not a sort key. In the
document of a JSON column, a secret field raises an error instead; see
Store a Pydantic model in a JSON column.
Details
The two markers work in different places. This matters when you use a schema outside a view:
WriteOnlysets Pydantic’sexcludeon the field. So every serialization of the schema drops it: the CRUD responses, a FastAPIresponse_model, and your ownmodel_dump()calls. This also holds in nested schemas, and the response schema in OpenAPI does not show the field.ReadOnlyworks only where Restly applies it: in the create and update bodies that Restly generates, and when Restly’s object helpers build or update an ORM object. On a schema that you use yourself, aReadOnlyfield validates and serializes like any other field. OpenAPI marks itreadOnly.
A marker must be the outer type of the field. Inside a union or a container,
as in Optional[WriteOnly[str]], WriteOnly[str] | None or
list[WriteOnly[str]], the marker would do nothing. The same holds inside a
type alias, such as type Secret = WriteOnly[str]. So Restly rejects such a
field with a
RestlyConfigurationError.
For an optional write-only field, write WriteOnly[Optional[str]].
A JSON column has its own rules for ReadOnly and WriteOnly fields; see
Store a Pydantic model in a JSON column.
Write the request body yourself#
A schema_create or
schema_update that
you set on the view is the whole contract for its request. Restly writes every
field that it declares, except the fields that the class itself marks
ReadOnly. The markers on the view’s schema only shape the bodies that Restly
generates.
So you can set a field on create and freeze it afterwards. Mark it ReadOnly
on the view’s schema, and declare it in your own create body:
class CommentSchema(fr.IDSchema):
body: str
story_id: fr.ReadOnly[int]
class CommentCreate(fr.BaseSchema):
body: str
story_id: fr.MustExist[int, Story]
class CommentView(fr.AsyncRestView):
prefix = "/comments"
model = Comment
schema = CommentSchema
schema_create = CommentCreate
POST /comments sets story_id. The generated update body leaves it out, so
PATCH ignores it.
Keep a field that the server stamps, such as a tenant id, out of every request body that you write. Otherwise a client can set it.
Validate input#
Each kind of rule has one place:
Rule |
Where it goes |
What the client gets |
|---|---|---|
The format or the range of one value |
A Pydantic validator or a |
422 |
A field that a client cannot set |
The value is ignored |
|
The shape of a JSON document |
A Pydantic model as the type of the column; see Store a Pydantic model in a JSON column |
422 |
A value that its column can hold, such as a number in the range of |
Nothing for the generic types of SQLAlchemy, a |
422, or 400 in a filter |
A referenced row must exist |
|
404 |
A value must be unique |
A unique constraint on the column; see Errors |
409 |
A rule that needs the database, the stored row or the caller |
The |
The error that you raise |
Whether the caller may run the action at all is not validation. It is a
policy, and it goes in
authorize, which answers with 403.
Value validation#
Put a validator or a Field constraint on the view’s schema. The create
body, the update body and the response class are subclasses of the view’s
schema, so all three run it:
import re
import pydantic
class OrganizationSchema(fr.IDSchema):
name: str = pydantic.Field(min_length=2)
slug: str
@pydantic.field_validator("slug")
@classmethod
def check_slug(cls, value: str) -> str:
if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", value):
raise ValueError("use lowercase letters, digits and hyphens")
return value
A value that fails returns 422, with Pydantic’s list of errors.
A rule that compares two fields, in a model_validator, works on the create
body. The update body holds only the fields that the client sends, and the
other fields are None. Check such a rule in update, where you have the
stored row too. Otherwise a PATCH that sends one of the two fields can save
a row that breaks the rule. The response class runs the validator too, so
every read of that row, and every list that holds it, then fails with a
server error.
Details
The validator also runs in these places:
In the update body, the client may leave out each field. Pydantic does not validate a field that the client leaves out, so the validator does not run for it. Each field keeps its type, so
nullis valid only where the type allowsNone, as in the create body.{"slug": null}returns 422 before the validator runs, so the validator above never getsNone. A validator withmode="before"runs before that check, so it still gets the client’snull.The response class runs the validator when Restly builds a response from a row. A row that breaks the rule, for example one that a script wrote, then fails with a server error.
List filters run it on each filter value; see Filter values go through the field’s validation.
A class without the field does not run its validator. The request bodies
have no ReadOnly fields and no primary key that the server generates. The
response class has no WriteOnly fields.
To check only what comes in, put the validator on a request body that you set
on the view. The SaaS example does this for organizations
(app/organizations/schemas.py).
Column validation#
Restly checks each value from a client against the type of its column, before the database sees the value. It checks the generic integer and text types of SQLAlchemy, so you do not write a validator for them:
A number must fit the integer type: 16 bits for
SmallInteger, 32 bits forInteger, from -2,147,483,648 to 2,147,483,647, and 64 bits forBigInteger.Text must not hold a NUL character (
\x00).Text in a request body must fit the length of a
String(n)column.
A value that does not fit gets 422 in a request body or in the path, and 400 in a filter. A reference id that does not fit gets 404, as for a row that does not exist.
Details
The type of the column is the rule on every database. PostgreSQL refuses
such a value with an error, which would be a 500. SQLite stores it, so the
tests of an app on SQLite would not see the problem. PostgreSQL cannot store
a NUL character in text, so Restly refuses it on every database. A column
that holds bigger numbers needs BigInteger, such as a column with a Unix
time in milliseconds. A filter only compares a value with the column, and
PostgreSQL compares text of any length, so the length counts only in a
request body. A CHAR without a length holds one character, as in SQL.
Restly checks these types by their exact class: Integer, SmallInteger,
BigInteger, String, Text, Unicode and UnicodeText, and the same
types by their SQL names: INTEGER, SMALLINT, BIGINT, VARCHAR,
NVARCHAR, CHAR, NCHAR and TEXT. Any other type gets the value as it
is, as in SQLAlchemy:
a type of a dialect, such as MySQL’s
TINYINT,a subclass of a type above, such as
Enum,a
TypeDecorator, such as SQLModel’sAutoString,a type from
with_variant().
The database then checks the value. On PostgreSQL, a value that does not fit is an error, and the client gets a 500. For a 422, put the limit on the field of the schema. Its validation runs on filter values too; see Filter values go through the field’s validation.
Column |
Field of the schema |
|---|---|
Text with a length, in a type that Restly does not check |
|
A smaller integer, such as MySQL’s |
|
|
|
|
The Python type: the enum class, or |
|
|
Text without NUL, in a type that Restly does not check |
An |
from typing import Annotated
from pydantic import AfterValidator
import fastapi_restly as fr
def no_nul(text: str) -> str:
if "\x00" in text:
raise ValueError("The text holds a NUL character.")
return text
class NoteSchema(fr.IDSchema):
text: Annotated[str, AfterValidator(no_nul)]
Database validation#
A schema sees one request. It does not see other rows, the stored row or the caller:
A reference to another row:
fr.MustExist,fr.IDRefandfr.IDSchema[Model]check that the row exists, and return 404 if it does not. See Relationships.A unique value, such as an email address: put a unique constraint on the column. The database rejects a second row with the same value, and Restly turns the error into a 409; see Errors.
A rule that needs the database or the caller, such as “no new tasks in an archived project”: check it in the
createorupdatebusiness method and raise an error there; see Request Lifecycle & Hooks. The SaaS example checks such rules inapp/tasks/views.py.
Keep times in UTC#
Give each time field the type
fr.UTCDatetime:
class MeetingSchema(fr.IDSchema):
name: str
starts_at: fr.UTCDatetime
The field reads a time without an offset, such as 2024-01-01T09:30:00, as
UTC. A time with an offset, such as 2024-01-01T10:30:00+01:00, becomes the
same time in UTC. So create and update get each time in UTC. A response
shows it with Z, the offset of UTC, as JavaScript’s toISOString() does: a
client that sends "starts_at": "2024-01-01T10:30:00+01:00" gets
"starts_at": "2024-01-01T09:30:00Z" back. A schema that Restly generates
gives each time column this type, and
fr.TimestampsSchemaMixin
gives it to created_at and updated_at.
A filter value goes through the same type, so
?starts_at__gte=2024-01-01T04:15:00-05:00 finds the meeting. In a URL,
write the + of an offset as %2B: a plain + means a space there.
Each column stores the time in UTC. A column without a time zone stores it
without the offset: 2024-01-01T09:30:00. On a base with
fr.ModelDefaults, a
Mapped[datetime] column has a time zone. A column with
mapped_column(DateTime()) has none, and neither has Mapped[datetime] on a
base without fr.ModelDefaults.
For local times, such as the start of a dinner booking in the time of the
restaurant, use a column without a time zone, and a field of type datetime
with a validator that converts the time. Restly then gets a time without an
offset, and keeps it as it is:
from datetime import datetime
from typing import Annotated
from zoneinfo import ZoneInfo
import pydantic
AMSTERDAM = ZoneInfo("Europe/Amsterdam")
def amsterdam_time(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(AMSTERDAM).replace(tzinfo=None)
class BookingSchema(fr.IDSchema):
starts_at: Annotated[datetime, pydantic.AfterValidator(amsterdam_time)]
A filter value goes through the field’s validation too, also in the filter of a react-admin view, so the filters convert it in the same way.
Details
Without these rules, the database drivers decide, and they do not agree. For a column without a time zone, asyncpg rejects a time with an offset, so the request fails with a 500. For a column with a time zone, asyncpg reads a time without an offset in the time zone of the app’s Python process. psycopg leaves both cases to PostgreSQL, which uses the time zone of the database session. On an engine that Restly builds from a URL, that session is in UTC; see Engine defaults. SQLite drops the offset of every time.
A field of type datetime keeps the time that the client sent. Restly still
changes the time to fit its column before the database sees it: in a filter,
and when it copies a request body onto the model, in
fr.objects.make_new_object
and fr.objects.update_object:
Column |
A time with an offset |
A time without an offset |
|---|---|---|
With a time zone |
becomes the same time in UTC |
gets the offset of UTC |
Without a time zone |
becomes the same time in UTC, without the offset |
stays as it is |
So authorize, create and update get the time as the client sent it,
and a response shows a time from a column without a time zone without an
offset.
Restly changes a time to fit a column only when the model has a column with
the name of the field. A field without one, such as a WriteOnly field that
only create reads, gets UTC from its type only.
A filter method of your own, from
fr.list_filter, gets its value as
the type of its parameter gives it. With value: fr.UTCDatetime, it gets the
time in UTC. Compare it with a column without a time zone as
value.replace(tzinfo=None).
A time from the database has no offset when its column has no time zone, and
on SQLite always. A time from the schema has an offset. Python cannot order
such times: <, > and - raise TypeError. And == is always False,
and != always True, without an error. Convert the time from the database
with fr.in_utc first, as in
data.ends_at > fr.in_utc(obj.starts_at). fr.in_utc follows the rule of
fr.UTCDatetime: a time without an offset is UTC, and a time with an offset
becomes the same time in UTC.
A column with a type of your own, a
TypeDecorator, gets a time with an offset as it
is, and so does a type with a variant for one database, from
with_variant(). Such a type may need the offset, as SQLAlchemy’s recipe
that stores UTC in a column without a time zone does, and a variant may
store a time zone where the type itself does not. Restly only checks that
the time fits in UTC. A time without an offset gets the offset of UTC when
the type stores a time zone. SQLAlchemy’s recipe stores none, and it refuses
a time without an offset, so give its column a field of type
fr.UTCDatetime, which gives every time the offset of UTC.
A time that is not between the years 1 and 9999 in UTC, such as
9999-12-31T23:30:00-01:00, gets 422 in a request body and 400 in a filter.
A date, and a time of day of type time, have no rule. A time of day has
no date, so summer time can change its time in UTC. A time column without
a time zone keeps the clock time of a value and drops its offset:
09:00:00+02:00 becomes 09:00:00.
Keep NaN and infinity#
JSON has no number for NaN or infinity. So Restly writes them as the text
"NaN", "Infinity" and "-Infinity", in a response and in a JSON column. A
float field reads the text back as a number, so a client can send back the
values that it got:
class Series(fr.IDMixin, Base):
points: Mapped[list[float]] = mapped_column(
fr.models.PydanticJSON(list[float])
)
A client that sends {"points": [1.5, "NaN", "Infinity"]} gets
{"id": 1, "points": [1.5, "NaN", "Infinity"]} back, and the column stores
[1.5, "NaN", "Infinity"]. OpenAPI still shows a number for the field, so a
client must accept these three texts in a number field.
A Pydantic model of your own, such as the model of a
fr.models.PydanticJSON column,
follows its own setting: in a response, and for its fields of type Any in
a JSON column. Pydantic’s default writes null there, which loses the
value. Give your models the setting that
fr.BaseSchema has:
import pydantic
class Point(pydantic.BaseModel):
model_config = pydantic.ConfigDict(ser_json_inf_nan="strings")
lat: float
lon: float
To give the setting to all of your models, put it in a base class of your own.
Restly cannot keep the value in these cases:
On SQLite, a
Floatcolumn stores NaN asNULL, and a column that is not nullable refuses the row. PostgreSQL stores NaN and infinity as numbers.A plain
JSONcolumn gets the text only when Restly copies a request body onto the model. Your own code that writes the column goes through SQLAlchemy. SQLite then stores NaN as a number, and PostgreSQL refuses the row. In a part of typeAny, a response shows such a number asnull. For a free-form document that your own code writes, usefr.models.PydanticJSON(dict[str, Any]), which writes the text on each write.The text in a response needs FastAPI 0.130 or later, with its default response class. An older FastAPI, or
JSONResponseas the response class, gives an error 500.
Details
A request body can hold the text, or NaN and Infinity without quotes, as
Python’s json module writes them. A float field reads both. Send the
text: FastAPI cannot write an error message that holds NaN without quotes,
so such a body can get an error 500 instead of 422. A strict field, with
strict=True, refuses the text in a request.
Restly reads a row into the response class in lax mode, also for a strict
class, as a PydanticJSON column reads its document. So a strict float
field reads the stored text back.
A part of type Any, such as a value in dict[str, Any], reads the text back
as text, not as a number. So does a union with str, such as float | str.
For Pydantic’s default in a response, set ser_json_inf_nan="null" in the
model_config of your schema.
PostgreSQL and MySQL refuse NaN as a number in a JSON column, because JSON has
no number for it. PostgreSQL’s to_jsonb('NaN'::float8) gives the text
"NaN", and Restly writes the same text.
Use other field names in JSON#
Use a normal Pydantic alias when the JSON name differs from the Python name:
from pydantic import Field
class UserSchema(fr.IDSchema):
first_name: str = Field(alias="firstName")
email: str
A request body must use firstName. The responses use firstName too. The
Python name stays first_name, and Restly writes the value to the model
attribute with that name. List filters use the alias as well; see
Filtering, Sorting & Pagination.
Set your own response class#
By default, the response class is the view’s schema without its WriteOnly
fields. Restly builds this class even when the schema has no WriteOnly
fields, so OpenAPI always shows UserResponse.
When what goes out differs more from what comes in, set
schema_response
on the view. Everything that goes out follows it:
every single response, and each item of the list response;
the fields that the list filters and sort keys use, so a client can filter only on the fields that go out;
the relationships that the view loads.
The request bodies still follow schema_create and schema_update, which
Restly derives from schema:
class AccountSchema(fr.IDSchema):
name: str
email: str
team_id: int | None = None
class AccountResponse(fr.IDSchema):
name: str
team: TeamSchema | None = None
class AccountView(fr.AsyncRestView):
prefix = "/accounts"
model = Account
schema = AccountSchema
schema_response = AccountResponse
POST /accounts takes name, email and team_id. Every response shows
id, name and the nested team, which the view loads. ?email=... is not
a filter, because a client does not see email. Restly uses the class as you
wrote it: it does not remove WriteOnly fields from it.
To change how one object becomes a response, or to send another shape, see Responses.
Details
Name the class <Resource>Response, as in AccountResponse. Restly names the
list response and the list params after it: AccountListResponse and
AccountListParams. For type checkers, put the class in the second type
parameter of the view, as in fr.AsyncRestView[Account, AccountResponse]; see
Type Annotations.
The names of the generated classes#
A class that Restly generates is named <Resource><Role>. Resource is the
class name of the view’s schema without a final Schema or Response. For
the view’s schema UserSchema, the resource is User:
Role |
Class |
View attribute |
|---|---|---|
response |
|
|
create body |
|
|
update body |
|
|
list response |
|
none |
list params |
|
A client sees these names in OpenAPI. It does not see the view’s schema, unless another schema nests it or a custom route names it. The list params are not a class in OpenAPI either: OpenAPI shows them as separate query parameters.
The 404 for a row that does not exist names the resource too, such as
User with id 5 was not found. To change the text, override
not_found.
Details
The list response has no view attribute, because it has nothing of its own
to set. It is the envelope of the view’s
pagination, filled with
the response class. To change it, change one of those two; see
Change the list envelope. A react-admin view has no
list response class: its list route returns a plain JSON list.
A view without a schema gets a generated one. For a model named User it is
UserSchema, so the names above stay the same. A schema with another name
keeps its full name as the resource: UserRead gives UserReadCreate.
Name your schemas#
OpenAPI keeps all classes in one list, by name. It has no modules. A generated client uses these names as type names. So each class in your API needs its own name, even when Python keeps two classes apart in different modules.
Restly names the classes that it generates after the view’s schema; see the names of the generated classes. Follow these rules for the classes that you write:
Name the view’s schema after the resource that the client sees:
UserSchema, orAdminUserSchemafor an admin view of users. Two schemas namedUserSchemain different modules clash.Use the names
UserResponse,UserCreateandUserUpdateonly for a view’s response class and its create and update body, and set the class on the view:class TaskCreate(fr.BaseSchema): title: str class TaskView(fr.AsyncRestView): prefix = "/tasks" model = Task schema = TaskSchema schema_create = TaskCreate
A class for another purpose, such as the body of a bulk route, gets a name that says what it is:
TaskBulkItem,UserSummary.Two views that share a schema also share the names of the classes that Restly generates. When the classes differ, for example because only one view is paginated, give one view a schema of its own:
class AllTasksSchema(TaskSchema): pass
The list response takes its name from the response class. So when the two views set the same
schema_response, give one view a response class of its own in the same way.
Details
Two different classes can still get the same name. For example, two views
share a schema but only one is paginated, or you wrote a UserCreate that is
not the same as the generated one. OpenAPI then shows both classes under long
names built from the module path, such as app__users__views__UserCreate.
When the module path is the same too, it numbers them, and the numbers follow
the order in which the views are registered. These names can change, and a
generated client then breaks.
When the app builds its OpenAPI spec, Restly checks for this and warns with a
RestlyDuplicateSchemaNameWarning
that names the classes and the views. Restly checks an app that you pass to
fr.configure(app, ...) or
configure_tests(app=...), or
that you include a view on. Two classes with the same name are not a problem
when their JSON schemas are the same, docstrings and defaults included:
OpenAPI then shows them as one.
Name the view’s classes in a custom route#
A custom route can show the same types in OpenAPI as the CRUD routes. A route
decorator runs in the class body, before Restly builds the view’s classes.
So a custom route names them with markers from fastapi_restly.views. When
the view registers, Restly puts the view’s class in place of each marker:
Marker |
The view’s class |
On |
|---|---|---|
|
||
the list response, with the view’s pagination |
|
|
|
||
|
||
|
from fastapi_restly.views import ViewID, ViewListResponse, ViewResponse
@fr.include_view(app)
class UserView(AppView):
prefix = "/users"
model = User
schema = UserSchema
class Action(fr.Action):
ACTIVATE = "activate"
@fr.get("/inactive")
async def inactive(self, list_params) -> ViewListResponse:
return await self.handle_get_many(list_params, where=User.active.is_(False))
@fr.post("/{id}/activate", status_code=200)
async def activate(self, id: ViewID) -> ViewResponse:
user = await self.get_one(id)
async with self.write_action(self.Action.ACTIVATE, obj=user):
user.active = True
return user
OpenAPI then shows UserListResponse and UserResponse for these routes,
as for the CRUD routes. A path parameter annotated ViewID gets the path
converter of the id type, as the item routes do: here /{id:int}; see
Item paths.
The routes return the list result and the row. Because they declare the
view’s response, Restly passes these through
to_response, as the
CRUD routes do; see
The response of a custom route.
A marker stands for the class of the view that registers. So a route on a
base view works for each view that subclasses it, with that view’s classes.
The TrashView of the SaaS example adds a trash list and a restore route to
two views this way, in
app/views.py.
Details
A marker works wherever the class could go: in a parameter annotation, in the
return annotation, in response_model=, and inside another type, such as
list[ViewResponse] or ViewResponse | None. Annotated[ViewID, Path(ge=1)]
keeps the Path(ge=1). A path that gives a converter, such as /{id:str},
keeps it.
For a type checker, each marker is Any, so it accepts a marker in any
annotation. A plain View has no model and
no schema, so a route on it cannot use the markers: registration raises
RestlyConfigurationError.
To use the classes outside a route, or the classes of another view, build
them with
derive_schema_response(schema)
and
derive_schema_list_response(schema_response, pagination=...).
They return the same classes that the view uses. Pass the view’s pagination
as it is, also when it is None. A list response with another envelope than
the view’s has another shape, for example data without total_count.
There are then two classes named UserListResponse, and Restly warns; see
Name your schemas. A function builds these classes, so
type checkers do not accept them in an annotation, as in
data: UserResponse. At runtime they are normal classes, so add
# type: ignore[valid-type] on such a line.
A custom route that names UserSchema or PaginatedEnvelope[UserSchema]
sends the same JSON, but shows a type of its own in OpenAPI.
Store a Pydantic model in a JSON column#
Annotate a column with a Pydantic model. On a base with
fr.ModelDefaults, the column is
then a fr.models.PydanticJSON
column. It stores the model as a JSON document, and it loads the document as
the model:
import pydantic
class Address(pydantic.BaseModel):
model_config = pydantic.ConfigDict(frozen=True)
street: str
city: str
class Shop(fr.IDMixin, Base):
name: Mapped[str]
address: Mapped[Address]
branches: Mapped[dict[str, Address]] = mapped_column(
fr.models.PydanticJSON(dict[str, Address])
)
The generated schema has the field address: Address. So a request with an
address that does not fit returns 422, and a response shows the address as a
JSON object. In your own code, shop.address is an Address. A type that is
not one model, such as dict[str, Address], needs mapped_column(), as
branches shows. On PostgreSQL, use
fr.models.PydanticJSONB for a
JSONB column. Alembic needs a hook in env.py for these columns; see
Write a PydanticJSON column into a migration.
As with SQLAlchemy’s JSON type, a change inside the document is not saved.
shop.address.city = "Utrecht" changes the object, but not the row. Assign a
new value:
shop.address = shop.address.model_copy(update={"city": "Utrecht"})
With frozen=True, as in the example, the mistake fails: the assignment to
shop.address.city raises a ValidationError. frozen does not reach into a
list in the model, so use a tuple, such as tuple[str, ...], for a list. A
dict at the top level, as in branches, is not frozen either. Wrap its type
in SQLAlchemy’s MutableDict. Then the
session saves a change such as shop.branches["south"] = Address(...):
from sqlalchemy.ext.mutable import MutableDict
branches: Mapped[dict[str, Address]] = mapped_column(
MutableDict.as_mutable(fr.models.PydanticJSON(dict[str, Address]))
)
The column validates the document each time it loads a row, also outside a view. So when you change the model, a stored document that no longer fits it makes each load of its row fail. A new field with a default is safe. For other changes, update the stored documents in a migration.
Use a PydanticJSON column for a document with a fixed shape. For a
free-form document, use a plain JSON column, as in
Mapped[dict[str, Any]] = mapped_column(JSON). For a document with a secret,
write a SQLAlchemy TypeDecorator of your own,
because the JSON dump masks the secret. Keep a value that the server sets,
such as the time it checked an address, in a column of its own. A request
writes the whole document, so it would replace such a value.
Details
Writes and reads. The column validates a value when it writes it. So a
dict works too, and so does another model with the same fields, such as a
schema class of your view. A value that does not fit fails at the flush.
Then the column dumps the value as TypeAdapter(Address) does in JSON mode,
with round_trip=True. A datetime, a UUID or a Decimal becomes text,
and a set becomes a list. A NaN or an infinity becomes text; see
Keep NaN and infinity. A serializer in the type, such as
a PlainSerializer, runs, and the column stores what it returns. A subclass of
Address is stored as an Address, without its own fields, as a field of
type Address is in model_dump(). The keys are the names of the fields,
not their aliases, also with serialize_by_alias=True. A response reads the
document by the names, as it reads the columns. A computed field is not
stored, so a model with extra="forbid" accepts the document. A Json[T]
field is stored as text, as its validation reads it. A load reads the stored
document in lax mode, also for a strict model. So it reads a datetime from
text, a tuple from a list, and a float from "NaN".
Refused types. Some types raise
RestlyConfigurationError
when you define the column, because the dump would not store all of the
value:
A
WriteOnly,Field(exclude=True)orField(exclude_if=...)field, also in a nested model or aTypedDict. The dump leaves the field out.A field of Pydantic’s secret types, such as
SecretStr, also in a nested model, aTypedDictor aNamedTuple. The dump masks it, so the column would store**********.A dataclass, and an iterator type, such as
Iterable.
A part typed Any is checked when you write it. For such documents, write a
TypeDecorator that stores the document as you need. Its bind processor gets
the Pydantic model itself, not a dump. You can still mark the whole column
WriteOnly. That hides the column in responses, and the stored document
keeps all its fields.
ReadOnly fields. A ReadOnly
field raises too, also in a nested model, a TypedDict or a NamedTuple. A
request writes the whole document. So a PATCH that leaves out the field, as
OpenAPI asks, would set it back to its default, and the value that the server
set would be lost. Keep such a value in a column of its own. You can still
mark the whole column ReadOnly. The id of a reference, such as
fr.IDRef[User], does not count, because the client sends it. So a view
schema, with its read-only id, cannot be the type of a document. Use a
model of its own for the document.
The same as JSON. None is stored as JSON null, and
fr.models.PydanticJSON(Address, none_as_null=True) stores SQL NULL. A query
reads a part of the document as with JSON, as in
Shop.address["city"].as_string() == "Utrecht". A dict or a model in a
query is dumped, but not validated, because it can be a part of a document.
JSONB for each model. The type_annotation_map of fr.ModelDefaults
has pydantic.BaseModel: fr.models.PydanticJSON(pydantic.BaseModel), as
SQLAlchemy maps an enum with enum.Enum: Enum(enum.Enum). Put
pydantic.BaseModel: fr.models.PydanticJSONB(pydantic.BaseModel) in the map
of your base, and each model gets a JSONB column. Your own entry wins; see
Change a default.
A plain JSON column with a schema of your own. A
Mapped[dict[str, Any]] column gets the field type dict[str, Any] in a
generated schema, which does not check the document. To check it only in the
view, write the schema yourself and give the field a Pydantic model as its
type, such as Address or dict[str, Address]. Restly dumps the value
before it assigns it to the column. Then it refuses the same fields, in each
model in the value. It cannot check the keys of a TypedDict or the fields
of a NamedTuple, because by then the value is a plain dict or tuple. The
column has no type of its own, so this dump goes by
the type of the value: a subclass keeps its own fields, and a serializer of
the field type, such as a PlainSerializer, does not run. A serializer in a
model does. A NaN or an infinity becomes text, as in a PydanticJSON
column; see Keep NaN and infinity. The model attribute
then holds a dict, not a model. A PydanticJSON column says the shape once,
on the model, and holds the model in all of your code.
Internals#
This section explains how Restly builds the classes. You do not need it to use them.
How the markers work#
ReadOnly and WriteOnly are typing.Annotated aliases. Each one adds a
marker object and a Pydantic Field to the type:
ReadOnly[T]isTwith the read-only marker and areadOnlyflag for the JSON schema.WriteOnly[T]isTwith the write-only marker, awriteOnlyflag for the JSON schema, andexclude=True. Pydantic’sexcluderemoves the field in every serialization of the schema. A value can only leak through a response that never passes through the schema, such as a raw dict or ORM object that a route returns without aresponse_model.
Restly finds a marker in the field’s top-level metadata. That is why a marker
inside a union or a container is rejected. BaseSchema checks this when the
class is defined. A view checks its own classes again when it registers, which
also covers classes that do not derive from BaseSchema.
How Restly builds the request bodies and the response class#
before_include_view()
builds the classes. For the view’s schema UserSchema:
schema_responsecomes fromderive_schema_response(). It is a subclass ofUserSchemathat deletes theWriteOnlyfields. Each call with the same schema returns the same class, so a custom route can name it.schema_createis a subclass ofUserSchemawithOmitReadOnlyMixinfirst in the MRO. That mixin deletes theReadOnlyfields fromcls.model_fieldsand rebuilds the class withmodel_rebuild(force=True).schema_updateaddsPatchMixinas well. After theReadOnlyfields are gone, it sets the default of every field toNone, so the client may leave out each field. The defaults ofUserSchemaare replaced byNone, not kept. Each field keeps its type, and Pydantic does not validate theNonedefault, also when the class setsvalidate_default=Trueor a validator setsalways=True. A field that the client does not send is not written: Restly writes only the fields inmodel_fields_set. In OpenAPI, no field of the update body is required.
Both request bodies also leave out a primary key that the server generates,
even when the schema does not mark it ReadOnly. Generated means an
autoincrement column, a column with a default, or a dataclass field outside
__init__. A primary key without any of these is a natural key, and the
client sends it.
The classes are subclasses, so they keep the validators and serializers of
UserSchema. When a class deletes a field, it keeps the validators and
serializers of that field too, and Pydantic does not run them there. So a
validator for a ReadOnly field, a WriteOnly field or a generated primary
key does not need check_fields=False.
Restly stores the classes on the view when it registers the view. Declare
schema_response, schema_create or schema_update on the view class to use
your own class instead.
When a subclass view gets new classes#
A subclass inherits the classes that its base view declares. Registration
builds a class again for the subclass in two cases: Restly generated it for
the parent, or what it is built from changes. A subclass that declares a new
schema gets a new schema_response, schema_create, schema_update and
schema_list_params. A subclass that declares a new schema_response gets
new schema_list_params. A view without a schema builds it from model, so
there a new model counts as a new schema. A class that Restly generates
for the subclass is not a change of its own, so it does not replace a class
that the base declares.
The list params are also built from pagination, the list settings such as
include_filters and sort_query_param, and the filter methods. A subclass
that changes one of them gets new schema_list_params when the base declares
the list params that Restly would generate for it, made with
derive_schema_list_params().
Other declared list params stay, such as ones from a narrower schema. A
subclass with other settings then fails when it registers, and the message
says what to change. List params that you write by hand take no list
settings and no filter methods. A new schema replaces them too, so a
subclass with a new schema declares its list params again.
How Restly generates a schema#
derive_schema(model, ...)
builds the schema of a view without one. It makes one field for each column
on the model’s SQLAlchemy mapper, inherited columns included. A column’s type
comes from its Mapped[T] annotation, or from the plain annotation on a
SQLModel table. Under from __future__ import annotations, Restly evaluates
the annotation string in the model’s module. When no annotation resolves, the
column type’s python_type is used. A column with neither raises TypeError
that names the attribute. Synonyms and composites get no field of their own:
the columns behind them already have one. A field allows null (None in
Python) only when its column is nullable. mapped_column(nullable=...)
decides this, as it does for SQLAlchemy, even when the Mapped[...]
annotation says otherwise.
The create body follows the model’s constructor, as SQLAlchemy does. The
client may leave out a field when its column is nullable, or when the
constructor does not require it and the model then still gives the column a
value. That value comes from the column, such as insert_default or
server_default, or from the constructor: a default other than None, or
a default_factory. Restly calls the constructor with the fields that the
client sent, so that value applies.
On a dataclass model, a column with only an insert_default or a
server_default is a required argument of the constructor, so its field is
required. To let the client leave it out, add default=None, as SQLAlchemy
does for a value that the database fills:
mapped_column(server_default="x", default=None). On SQLAlchemy 2.0, a JSON
column stores that None as JSON null instead of its default, so Restly
keeps such a field required. Use default_factory=list, or
JSON(none_as_null=True), for a JSON column. For a column that the client
must never write, such as a Computed column, use init=False, as
SQLAlchemy does. The field is then ReadOnly.
Three rules decide the rest:
Base classes: Restly checks whether the model has fields named
id,created_atandupdated_at, and picksIDSchema,TimestampsSchemaMixinandBaseSchemafrom that. It does not look at the model’s Python base classes. A model with a field namedidfor another reason also getsIDSchema.ReadOnly fields: the fields
id,created_atandupdated_atare alwaysReadOnly. So is acolumn_propertyover a SQL expression, which cannot be written. So is a column that the constructor does not take (init=False), such as a tenant column with aninsert_default: the model or the server sets it, so the client must not. To let the client write such a column, removeinit=False, so that the constructor takes it. Any other column that the server fills, for example with a server default, is notReadOnly.Relationships: never included. A foreign key column, such as
customer_id, is an ordinary field. Withinit=False, it isReadOnly, as the rule above says. To write a relationship, or to show it in a response, write the schema yourself:customer: fr.IDRef[Customer]for its id, or a nested schema for the whole row. See Choose a reference style.
A view and a direct call of derive_schema(model) give the same schema.
Column types and field types#
convert_sqlalchemy_type_to_pydantic maps the Python type of each column,
from its annotation or its column type, to a Pydantic field type, as in this
table. A type keeps its name, except a time: it gets
fr.UTCDatetime.
SQLAlchemy / Python annotation |
Pydantic field type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
same enum subclass |
Pydantic model, such as |
same model |
|
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
SQLAlchemy |
|
Any and other generic types, such as tuple[int, int], also stay as they
are, and so does a union, such as Cat | Dog. Any other type raises
TypeError when Restly generates the schema. For a custom column type, write
the schema yourself.
See also#
Relationships: foreign keys and relationships as ids or nested objects, and the check that a referenced row exists.
Responses: how one object becomes a response, envelopes, and a different schema for the list route.
Filtering, Sorting & Pagination: the filters and sort keys that come from the response class.
Models: values that the server sets on every write path.