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 generated routes put on the wire:

Route

Response body

GET /{id}, POST, PATCH

The bare object, serialized through schema via to_response_schema

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 UserRead */ ],
  "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.

To return every matching row uncapped, set paginated to False. 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 = TagRead
    paginated = False
    # Response: {"data": [ /* every TagRead */ ]}

Restly keeps response_model and OpenAPI in sync with the envelope automatically: the list route’s response annotation is a generated PaginatedEnvelope wrapping the response schema (a plain Envelope when paginated is false), so no route-shell code is needed.

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

Custom envelopes#

Any other envelope is a change to the HTTP contract, so replace the endpoint method and set response_model on the replacement. Inside the shell, call to_response_schema(obj) so that WriteOnly stripping, relationship-id resolution, and response-schema validation still run.

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

import pydantic


class UserEnvelope(pydantic.BaseModel):
    data: UserRead


@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    schema = UserRead

    @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_response_schema(obj)}

    @fr.post("/", response_model=UserEnvelope)
    async def create_endpoint(self, schema_obj):
        obj = await self.handle_create(schema_obj)
        return {"data": self.to_response_schema(obj)}

For a list {"data": ..., "meta": ...} wrapper, replace get_many_endpoint. Keep the query_params parameter, which Restly annotates with the generated filter, sort, and pagination query parameters, and reshape the default envelope from to_listing_response() rather than redoing the page math by hand:

import pydantic


class PageMeta(pydantic.BaseModel):
    total_count: int
    page: int
    page_size: int
    total_pages: int


class UserListEnvelope(pydantic.BaseModel):
    data: list[UserRead]
    meta: PageMeta


@fr.include_view(app)
class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    schema = UserRead
    default_page_size = 50

    @fr.get("/", response_model=UserListEnvelope)
    async def get_many_endpoint(self, query_params):
        result = await self.handle_get_many(query_params)
        page = self.to_listing_response(query_params, result)
        return {
            "data": page["data"],
            "meta": {
                "total_count": page["total_count"],
                "page": page["page"],
                "page_size": page["page_size"],
                "total_pages": page["total_pages"],
            },
        }

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 replaced shell delegate to it. to_response is the shared runtime boundary keyed on the wire shape: SINGLE, LISTING, or EMPTY. Its place among the override points is covered in Customize RestView.

    def to_response(self, obj_or_list, shape=fr.ResponseShape.SINGLE):
        if shape is fr.ResponseShape.SINGLE:
            return {"data": self.to_response_schema(obj_or_list)}
        return super().to_response(obj_or_list, shape)

Be aware that overriding to_response without also replacing the shells leaves the generated shells’ 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 replaced shell with a matching response_model.

See also#