Response Envelopes and List Metadata#

Restly returns bare objects and a data envelope for lists; this page covers changing the container around the data, not the fields inside it.

What Restly returns by default#

Before changing the container, it helps to know what the default routes put on the wire:

Route

Response body

GET /{id}, POST, PATCH

The bare object, serialized through schema_response via to_single_response

GET /

A data envelope wrapping the page, plus pagination metadata (total_count / page / page_size / total_pages)

DELETE /{id}

204 No Content, empty body

The list envelope#

List endpoints paginate by default: the route wraps the page of objects in a data envelope and adds pagination metadata.

{
  "data": [ /* page of UserSchema */ ],
  "total_count": 123,
  "page": 2,
  "page_size": 50,
  "total_pages": 3
}

When the client omits ?page_size=, the endpoint uses default_page_size (50). An explicit page_size may be smaller or larger than that default; max_page_size is the ceiling above which the request is rejected with 422. Both are settings of the view’s pagination.

To return every matching row uncapped, set pagination to None, short for fr.NoPagination(). The data envelope stays, but the count and page fields drop away:

@fr.include_view(app)
class TagView(fr.AsyncRestView):
    prefix = "/tags"
    model = Tag
    schema = TagSchema
    pagination = None
    # Response: {"data": [ /* every TagSchema */ ]}

Restly keeps response_model and OpenAPI in sync with the envelope automatically: the list route’s response annotation is the pagination’s envelope filled with the response class, by default a PaginatedEnvelope (a plain Envelope without pagination), so no endpoint method code is needed. OpenAPI names it after the response class: TagListResponse for TagResponse, which Restly derives from TagSchema.

For how clients request pages (the page and page_size inputs), see Pagination in the query-modifiers guide.

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 on the wire with alias, or rename them all with an alias_generator.

  • Give an extra field a default, and it is sent as is.

  • Reshape the values in a model_validator(mode="before"), which receives them as a dict.

The FastAPI full-stack template’s {"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)

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

Set the pagination on a project base view to give every list the same envelope; see Set it once for every view. A custom list route on such a view names the view’s list response class. Build it at module level from the response class and the same pagination:

ItemResponse = fr.schemas.derive_schema_response(ItemSchema)
ItemListResponse = fr.schemas.derive_schema_list_response(
    ItemResponse, pagination=APP_PAGINATION
)

Then response_model=ItemListResponse gives the custom route the same type in OpenAPI as GET /; see A custom route names the same classes. Coming from fastapi-pagination shows the envelope that keeps fastapi-pagination’s field names.

Creating the pagination settings builds an empty page from the envelope, so a required field Restly cannot fill, such as a misspelled totl_count, fails at startup instead of on a request. Leave the envelope’s extra setting at its default: Restly passes it every page value, and it keeps the fields it declares.

Without pagination#

A view that returns every row takes an envelope through fr.NoPagination. Restly fills it from data and total_count, the number of rows, and runs no count query. So the {"data": [...], "count": 123} 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 TagSchema */ ]

Custom envelopes#

An envelope around a single object, or a list shape no envelope model can express, such as a header, is a change to the HTTP contract. So replace the endpoint method and set response_model on the replacement.

In the replacement endpoint method, call to_single_response(obj) before placing the object in the envelope. This converts the ORM object to an instance of the view’s response class, without requiring write-only input fields.

For a single-object {"data": ...} wrapper, replace get_one_endpoint and create_endpoint:

import pydantic


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, schema_obj):
        obj = await self.handle_create(schema_obj)
        return {"data": self.to_single_response(obj)}

A replacement for get_many_endpoint keeps the list_params parameter, which Restly annotates with the generated filter, sort, and pagination query parameters. To reuse the page math, 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, such as page.data and page.total_count with the default envelope.

Envelope several routes at once: to_response#

When the same wrapper applies to more than one route, centralize it in to_response() and have each replacement endpoint method delegate to it. to_response is the shared runtime boundary keyed on the wire shape: SINGLE, LIST, or EMPTY. Its place among the override points is covered in Customizing RestView.

    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)

Be aware that overriding to_response without also replacing the endpoint methods leaves their response_model describing the bare object, so FastAPI response validation and OpenAPI disagree with the enveloped payload you return. A new contract therefore needs both pieces: the to_response override for the runtime shape, and a replacement endpoint method with a matching response_model.

See also#