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

result

What to_response returns

SINGLE

one ORM object

to_single_response(obj): an instance of the response class

LIST

a ListResult

to_list_response(result): the list envelope, filled with instances of the response class

EMPTY

None

an empty 204 No Content response

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:

Errors do not pass through to_response. To change the error shape, see Errors.

What Restly returns by default#

Route

Response body

GET /{id}, POST, PATCH

The bare object, an instance of the response class

GET /

A data envelope around the page, with total_count, page, page_size and total_pages

DELETE /{id}

204 No Content, with an empty body

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 an alias_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

ViewResponse

a row of the view’s model

to_response(row)

list[ViewResponse]

a list of rows, or another iterable of rows, such as await self.session.scalars(stmt)

to_single_response(row) for each row

ViewListResponse

a ListResult

to_response(result, ResponseShape.LIST)

the view’s response class itself, as in response_model=ProjectResponse

a row

the same as for ViewResponse

the view’s model, as in -> Project

a row

the same as for ViewResponse

list[Project]

a list of rows

the same as for list[ViewResponse]

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#