Skip to content

Model successful responses

Tenchi validates successful response bodies and headers before they cross the HTTP boundary. Use the simple contract fields when success has one fixed shape; use response definitions when status or representation depends on the result.

For ordinary JSON responses, Tenchi serializes the value, checks those exact bytes against the published serialization schema, and reads them back through Pydantic before the request context exits. A failure becomes a framework-owned 500 and reaches the context as an exception. A transaction scope configured to roll back on exceptions can then undo its writes; Tenchi does not create that transaction or undo writes already committed by an adapter. See database transactions for the context setup and Constraints for response requirements.

One successful response

class CreatedTodoHeaders(BaseModel):
    location: str = Field(alias="Location")


create_todo_contract = contract(
    method="POST",
    path="/todos",
    request=CreateTodo,
    response=Todo,
    response_headers=CreatedTodoHeaders,
    status=201,
)


def create_todo_headers(todo: Todo) -> CreatedTodoHeaders:
    return CreatedTodoHeaders(Location=f"/todos/{todo.id}")


route(
    create_todo_contract,
    create_todo,
    response_headers=create_todo_headers,
)

The synchronous header projector keeps HTTP metadata out of the use case. Tenchi checks that its return annotation and fixed scalar fields match the contract at composition time.

Presenters and header projectors may raise a declared AppError. It uses the same application-error response as an error raised by the use case; undeclared errors remain framework-owned 500 responses.

Status-dependent responses

The following route creates or replaces a todo. Implement put_todo with the signature shown below, returning both the saved todo and whether it was created. The function body depends on your repository's create-or-replace operation.

from dataclasses import dataclass
from pydantic import BaseModel
from tenchi.contracts import contract
from tenchi.responses import PresentedResponse, present, response
from tenchi.routes import route

from app.server.context import AppContext

from .schemas import CreateTodo, Todo


class PutTodoParams(BaseModel):
    todo_id: str


@dataclass(frozen=True, slots=True)
class PutTodoResult:
    todo: Todo
    created: bool


async def put_todo(
    params: PutTodoParams,
    request: CreateTodo,
    context: AppContext,
) -> PutTodoResult: ...


created = response(
    Todo,
    status=201,
    description="Todo created",
    examples={
        "created": Todo(id="todo_123", title="Buy milk", completed=False)
    },
)
existing = response(Todo, status=200, description="Todo replaced")

put_todo_contract = contract(
    method="PUT",
    path="/todos/{todo_id}",
    params=PutTodoParams,
    request=CreateTodo,
    responses=(created, existing),
)


def present_put(result: PutTodoResult) -> PresentedResponse:
    return present(created if result.created else existing, result.todo)


put_todo_route = route(put_todo_contract, put_todo, present=present_put)

The use case returns domain-shaped data. A synchronous presenter selects the declared wire response. The typed client exposes the selected definition on ClientResponse.definition. Examples belong on the individual definition so OpenAPI attaches each one to the correct status and media type. Tenchi validates and serializes them the same way as singular response_examples= values.

Alternative body schemas

When one status accepts alternative top-level bodies, pass them separately so Pyright preserves the precise union:

found = response(Todo, ArchivedTodo, status=200)

Nested unions use ordinary Python spelling:

many = response(list[Todo | ArchivedTodo], status=200)

Empty and passthrough responses

Use response(None, status=204) for an empty result and select it with present(definition).

For streaming, files, or redirects, declare passthrough=True and present a Starlette Response. Tenchi preserves it while validating the contract-owned status, media type parameters, and declared headers.

Passthrough remains declared

Passthrough is not an escape hatch around the contract. A streaming response declares its body type, and its status, content type, and headers must match the selected response definition.

Constraints