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 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
- Serialization is checked on the value actually returned, so an already-created model whose fields or nested collections were mutated after construction is checked too. Custom serializers must produce schema-valid JSON that the declared type can read back.
- Response field aliases must be readable by the same model. Prefer
Field(alias="wire_name")for a shared input and output name. With separate validation and serialization aliases, include the output name in the accepted validation aliases, for exampleAliasChoices("input_name", "wire_name"). Unreadable aliases fail during application composition, OpenAPI generation, and client preflight, including inside nested response types. - JSON Schema validation resolves local references only and never fetches remote schemas during a request. Use self-contained schemas for custom response types.
- A required response-header field cannot accept
None, because a null value has no HTTP header representation. Make the field optional when omitting the header is valid. - An empty passthrough definition must present a Starlette response with a materialized empty body.
- Presenters and header projectors may raise a declared
AppError; undeclared errors remain framework-owned 500 responses.