Responses#
Every CRUD endpoint method ends with one call,
self.to_response(result, shape).
The shape says what result is, and to_response passes it on:
Shape |
|
What |
|---|---|---|
one ORM object |
|
|
|
||
|
an empty |
FastAPI then serializes the value with the route’s response_model. Restly
sets it when the view registers: the response class for a single object, and
the list response class for the list. So the response and OpenAPI show the same
shape.
The response class decides which fields go out. By default it is the view’s
schema without its WriteOnly fields; see
Set your own response class. This page covers what is
around the fields, and where each change goes:
the envelope of every list: the pagination’s envelope; see Change the list envelope;
the response of a custom route that returns a row; see The response of a custom route;
the shape of one route, such as an envelope around a single object: a replaced endpoint method; see Change the shape of one route;
the shape of every route:
to_response; see Change the shape of every route;how one object becomes a response:
to_single_response; see Change how one object is serialized;a smaller schema for the list route; see Use a different schema for the list route.
Errors do not pass through to_response. To change the error shape, see
Errors.
What Restly returns by default#
Route |
Response body |
|---|---|
|
The bare object, an instance of the response class |
|
A |
|
|
The list envelope looks like this:
{
"data": [ /* a page of UserResponse */ ],
"total_count": 123,
"page": 2,
"page_size": 50,
"total_pages": 3
}
A view without pagination sends only data; see
pagination = None. How a client asks for a page, and
the page size limits, are on
Filtering, Sorting & Pagination.
The list route’s response_model is the pagination’s envelope, filled with
the response class. By default that envelope is a
PaginatedEnvelope, and a
plain Envelope for a view without
pagination. OpenAPI names the filled class after the response class:
TagListResponse for TagResponse, which Restly derives from TagSchema.
Change the list envelope#
The list envelope is a Pydantic model, and you can replace it. Write a
generic model with one type parameter for the items, and set it as the
envelope of the view’s pagination. Restly fills its fields by name from
data, total_count, page, page_size and total_pages. The response
and OpenAPI follow the model.
Leave a field out to drop it from the response.
Rename a field in the JSON with
alias, or rename them all with analias_generator.Give an extra field a default, and it is sent as it is.
Reshape the values in a
model_validator(mode="before"), which receives them as a dict.
The FastAPI full-stack template sends {"data": [...], "count": 123}:
from typing import Generic, TypeVar
import pydantic
T = TypeVar("T")
class DataCount(pydantic.BaseModel, Generic[T]):
data: list[T]
total_count: int = pydantic.Field(alias="count")
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
pagination = fr.NumberedPagination(envelope=DataCount)
To give every list the same envelope, set the pagination on a project base
view; see Set the pagination once for every view. A custom list
route can then name the view’s list response class, so OpenAPI shows the same
type as for GET /; see
Name the view’s classes in a custom route.
Coming from fastapi-pagination shows the
envelope that keeps fastapi-pagination’s field names.
Details
An envelope with camelCase field names, totalCount, pageSize and
totalPages:
from pydantic.alias_generators import to_camel
class CamelPage(pydantic.BaseModel, Generic[T]):
model_config = pydantic.ConfigDict(alias_generator=to_camel)
data: list[T]
total_count: int
page: int
page_size: int
total_pages: int
The metadata nested under meta:
class PageMeta(pydantic.BaseModel):
total_count: int
page: int
page_size: int
total_pages: int
class DataMeta(pydantic.BaseModel, Generic[T]):
data: list[T]
meta: PageMeta
@pydantic.model_validator(mode="before")
@classmethod
def nest_meta(cls, values):
if isinstance(values, dict) and "meta" not in values:
values = dict(values)
return {"data": values.pop("data"), "meta": values}
return values
Restly builds an empty page from the envelope when you create the pagination
settings. So a required field that Restly cannot fill, such as a misspelled
totl_count, fails at startup and not on a request. Leave the envelope’s
extra setting at its default: Restly passes it every page value, and it
keeps the fields that it declares.
Change the envelope of a view without pagination#
A view that returns every row takes an envelope through
fr.NoPagination. Restly fills it
from data and from total_count, the number of rows, and runs no count
query. So the DataCount model above works here too:
@fr.include_view(app)
class TagView(fr.AsyncRestView):
prefix = "/tags"
model = Tag
schema = TagSchema
pagination = fr.NoPagination(envelope=DataCount)
For a bare JSON array, use a generic RootModel that keeps only data:
class Items(pydantic.RootModel[list[T]], Generic[T]):
@pydantic.model_validator(mode="before")
@classmethod
def rows_only(cls, values):
return values["data"] if isinstance(values, dict) else values
@fr.include_view(app)
class TagView(fr.AsyncRestView):
prefix = "/tags"
model = Tag
schema = TagSchema
pagination = fr.NoPagination(envelope=Items)
# Response: [ /* every TagResponse */ ]
The response of a custom route#
A custom route that declares the view’s response class goes through the same response step as the CRUD routes. So it can return the row itself:
from fastapi_restly.views import ViewID, ViewResponse
@fr.include_view(app)
class ProjectView(fr.AsyncRestView):
prefix = "/projects"
model = Project
schema = ProjectSchema
class Action(fr.Action):
ARCHIVE = "archive"
@fr.post("/{id}/archive")
async def archive(self, id: ViewID) -> ViewResponse:
project = await self.get_one(id)
async with self.write_action(self.Action.ARCHIVE, obj=project):
project.archived = True
return project
Restly passes the row to
to_response, as
PATCH /{id} does. So an override of
to_single_response
changes this response too, and OpenAPI shows ProjectResponse.
What Restly does depends on what the route declares, in its return
annotation or in response_model=:
The route declares |
The route returns |
Restly sends |
|---|---|---|
a row of the view’s model |
|
|
|
a list of rows, or another iterable of rows, such as |
|
|
||
the view’s response class itself, as in |
a row |
the same as for |
the view’s model, as in |
a row |
the same as for |
|
a list of rows |
the same as for |
Each of these may allow None, as in -> ViewResponse | None, and a None
goes out as null. Any other value goes out as it is: a dict, an instance
of a schema, or a fastapi.Response. So a route that calls
self.to_response(row) itself works the same way.
Details
A route that names another class, such as
response_model=ProjectSummary, keeps FastAPI’s behaviour. FastAPI builds
the response from the value that the route returns, and
to_single_response does not run. So a value that to_single_response
adds is missing, and a change that it makes does not happen. When it cleans
up a stored value, such as an old color code, the stored value can fail the
validation of the response, and the client gets a 500. To run
to_single_response, declare the view’s response.
The view’s model counts only in the return annotation, not in
response_model=. Restly then sets the view’s response class as the
response_model. This is not what plain FastAPI does: FastAPI cannot use a
SQLAlchemy class as a response model. But the view sets the response class
for its model, so a route that returns the model returns the view’s
response.
The markers also give a custom route the view’s types in OpenAPI; see Name the view’s classes in a custom route.
Change the shape of one route#
An envelope around a single object changes the HTTP contract. So does a list
shape that no envelope model can express, such as a count in a header. For
these, replace the endpoint method and set a
matching response_model on the replacement.
For a {"data": ...} wrapper around a single object, replace
get_one_endpoint and
create_endpoint:
import pydantic
from fastapi_restly.views import ViewCreate
class UserEnvelope(pydantic.BaseModel):
data: UserSchema
@fr.include_view(app)
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema
@fr.get("/{id}", response_model=UserEnvelope)
async def get_one_endpoint(self, id: int):
obj = await self.handle_get_one(id)
return {"data": self.to_single_response(obj)}
@fr.post("/", response_model=UserEnvelope)
async def create_endpoint(self, data: ViewCreate):
obj = await self.handle_create(data)
return {"data": self.to_single_response(obj)}
Call to_single_response(obj) before you put the object in the envelope.
ViewCreate keeps the view’s create body; see
Name the view’s classes in a custom route.
Details
It turns the ORM object into an instance of the response class, which has no
WriteOnly fields. The ORM object often has no value for those fields, such
as a plain password, so it may not validate as the view’s schema.
A replacement for
get_many_endpoint
should keep the list_params parameter: Restly fills it with the filter, sort
and page query parameters. To reuse the page numbers, pass the result of
handle_get_many to
to_list_response(result).
It returns an instance of the view’s envelope model, so read its attributes:
page.data and page.total_count with the default envelope.
Change the shape of every route#
When the same wrapper applies to more than one route, put it in
to_response(). The
default endpoint methods all return through it:
def to_response(self, result, shape=fr.ResponseShape.SINGLE):
if shape is fr.ResponseShape.SINGLE:
return {"data": self.to_single_response(result)}
return super().to_response(result, shape)
This override changes only the value. The response_model of each CRUD route
still describes the bare object. So FastAPI’s response validation fails, and
OpenAPI shows the wrong shape. A new contract needs both parts: the
to_response override for the value, and a replaced endpoint method with a
matching response_model for each route that changes; see
Change the shape of one route.
to_response knows the shape of the response, not the action. It cannot tell
create from
get_one: both are SINGLE.
To change the status code or the headers of one route, replace that endpoint
method; see Change the status code or headers of one route.
Change how one object is serialized#
to_single_response(obj)
turns one ORM object into an instance of the response class. It reads the
attributes of the object and validates them with Pydantic. Each endpoint
method calls it, through to_response, and so does each item of a list.
Override it to send other values, or to build the response faster when you trust the data:
def to_single_response(self, obj: User):
return self.schema_response.model_construct(
id=obj.id,
name=obj.name,
email=obj.email,
)
model_construct() skips the validators and the check for required fields.
So make sure that the values match the response class. Build an instance of
the response class, not of the view’s schema: Restly sends an instance of the
response class as it is, and converts an instance of any other class first.
Details
An override may return an instance of another model, such as the view’s schema. Only the fields of the response class go out; see Internals for how Restly converts it.
Use a different schema for the list route#
There is no schema_list attribute. A different list shape changes the HTTP
contract, so it belongs in the endpoint method. Replace
get_many_endpoint
with your own response_model, and serialize through the smaller schema.
Filtering, sorting and pagination keep working:
class UserSummary(fr.IDSchema):
name: str
class UserView(fr.AsyncRestView):
prefix = "/users"
model = User
schema = UserSchema # the other routes keep the full schema
@fr.get("/", response_model=list[UserSummary])
async def get_many_endpoint(self, list_params):
result = await self.handle_get_many(list_params)
return [
UserSummary.model_validate(u, from_attributes=True)
for u in result.objects
]
This route returns a bare list. The filters and sort keys still follow the
view’s response class, not UserSummary.
Internals#
How Restly converts the value of to_single_response#
The response class that Restly builds is a subclass of the view’s schema.
Restly passes the value of to_single_response() through one more step before
it sends it:
An instance of the response class goes out as it is.
An instance of a subclass of the response class, or of the view’s schema when Restly built the response class, is copied into the response class. Restly does not validate the values again: they are already valid, and a validator that changes a value would change it twice.
An instance of another Pydantic model is validated into the response class from its attributes. A response class that the view sets itself can have other fields and types, so Restly cannot copy into it.
In each case only the fields of the response class go out.
See also#
Schemas & Validation: which fields the response class has.
Request Lifecycle & Hooks: how to replace an endpoint method.
Filtering, Sorting & Pagination: the page parameters that clients send.
Errors: the error shape, which does not pass through
to_response.Return related objects in a response: how nested relationship fields load, and
MissingGreenleton async.