Declare an HTTP contract
A contract is the source of truth for one HTTP operation. It is immutable data, separate from the use case that implements the operation.
Declare an operation
Keep shared input and output models in app/features/todos/schemas.py:
# app/features/todos/schemas.py
from pydantic import BaseModel, Field
class CreateTodo(BaseModel):
title: str = Field(min_length=1, max_length=120)
class Todo(BaseModel):
id: str
title: str
completed: boolImport those same models when declaring the operation. This example includes
the Location response header used by the generated todos application:
# app/features/todos/contracts.py
from pydantic import BaseModel, Field
from tenchi.contracts import contract
from .schemas import CreateTodo, Todo
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,
request_examples={"create": CreateTodo(title="Buy milk")},
response_examples={
"created": Todo(id="todo_123", title="Buy milk", completed=False)
},
name="create_todo",
summary="Create a todo",
tags=("todos",),
)Pydantic's TypeAdapter validates every declared boundary type. You may use
Pydantic models, dataclasses, standard collections, unions, and other types
Pydantic supports; inputs are not restricted to BaseModel subclasses.
Input sources
Contracts name four validated input sources:
| Option | Source | Use-case parameter |
|---|---|---|
request= | Request body | request |
params= | Path parameters | params |
query= | Query string | query |
headers= | Request headers | headers |
class GetTodoParams(BaseModel):
todo_id: str
class RequestHeaders(BaseModel):
api_version: str = Field(alias="X-API-Version")
get_todo_contract = contract(
method="GET",
path="/todos/{todo_id}",
params=GetTodoParams,
headers=RequestHeaders,
response=Todo,
)Field aliases are the wire names for path, query, and header fields and appear
the same way in OpenAPI. route() verifies that path placeholders match the
declared parameter schema.
Every path, query, and header field needs one unambiguous wire encoding. Tenchi rejects shapes that lack one when the route, server, or OpenAPI document is composed. The exact rules are listed under Constraints.
Media types
JSON is the default. Declare a different body or response representation with
request_media_type= and response_media_type=. Text declarations validate a
string; other non-JSON media types validate bytes or strings. Requests with a
missing or mismatched Content-Type fail with a framework-owned 415 before
decoding. Incompatible body annotations fail when the contract is declared;
for example, an object model cannot be paired with text/plain, and raw bytes
cannot be a JSON response body.
JSON response bodies are checked against their published schema and read back through the declared type before the request scope commits. See successful responses for that behavior and its rollback rules.
Paginate collections
tenchi.pagination provides one small limit-and-offset vocabulary that stays
typed across the contract, use case, client, and OpenAPI document:
from tenchi.contracts import contract
from tenchi.pagination import Page, PageQuery
class ListTodosQuery(PageQuery):
completed: bool | None = None
list_todos_contract = contract(
method="GET",
path="/todos",
query=ListTodosQuery,
response=Page[Todo],
)PageQuery declares limit and offset with boundary validation. Subclass it
to add filters or sorting fields owned by the operation. Page[Todo]
serializes one stable envelope with items, total, limit, and offset.
The use case builds the page from the values its port returns:
from tenchi.pagination import Page, page
async def list_todos(
query: ListTodosQuery,
context: AppContext,
) -> Page[Todo]:
items, total = await context.todos.list_page(
limit=query.limit,
offset=query.offset,
completed=query.completed,
)
return page(items, total=total, query=query)page() copies the validated limit and offset into the response, so the
repository result and the public envelope stay aligned. Memory adapters may
slice a list, but production repositories should apply limit, offset, and
filters in the database or remote service and return the page plus the total
count through the port. The typed client returns Page[Todo], and OpenAPI
includes the query constraints and item schema without another declaration.
Give callers concrete examples
Use request_examples= and response_examples= when a caller would benefit
from seeing a complete valid exchange. Each mapping key is a stable,
human-readable example name. Values are ordinary instances of the declared
types—not hand-written JSON.
When OpenAPI is generated, Tenchi validates an isolated copy of each value, serializes it with Pydantic and its wire aliases, revalidates that wire payload, and verifies it against the schema it publishes. A stale value, a non-round-trip validator/serializer pair, or a serializer that contradicts the schema fails generation without including the example payload in the error. Examples appear under the operation's request or response media type, where OpenAPI clients and agents can discover them.
For a contract with responses=, put examples={...} on each
response() definition so every example stays attached to its
status and media type. Examples are public API documentation: use realistic
placeholder values, never credentials, personal data, or production payloads.
Metadata and visibility
summary, description, and tags feed OpenAPI. deprecated accepts True
or a timestamp; sunset records the removal date.
public defaults to False. Authentication hooks can use public=True to
exempt an operation, and OpenAPI uses the same value when security schemes are
configured. The metadata does not authenticate or authorize a request by
itself.
webhook=True marks a signed inbound operation. Unlike descriptive metadata,
it is enforced: create_app() requires a matching exact-body verifier binding.
See Receive signed webhooks.
idempotency_key=True makes an unsafe operation's retry guarantee explicit.
The contract must declare one required, non-empty string Idempotency-Key
header. Tenchi checks both Pydantic validation and serialization schemas and
publishes the guarantee in OpenAPI. The flag does not implement replay or
storage; follow Make operations safe to retry for that work.
Authentication hooks should exempt operations through
info.contract.public, not URL paths or documentation tags. OpenAPI uses the
same metadata to remove global security from public operations.
Runtime limits
max_request_bytes=overrides the application's request-body limit for one operation.timeout=cooperatively cancels overdue HTTP work, waits for request-scope cleanup, and returns a framework-owned504. It does not follow the use case into tools, jobs, tasks, or direct execution.errors=declares the application failures callers are allowed to observe.responses=declares status-dependent successful outcomes.
Invalid combinations and unsupported schemas fail when contract() is called.
Continue with routes and server, responses, and
errors.
Constraints
Tenchi enforces these rules when the contract is declared or when the application, OpenAPI document, or typed client is composed.
- Path fields serialize as one non-null scalar value and cannot be nullable.
- Query fields serialize as one scalar value or an array of non-null scalar values; arrays use repeated query keys. A field cannot combine scalar and array alternatives because a single query value would be ambiguous. Fixed-length tuples of scalar values remain supported.
- Request-header fields serialize as one scalar value. Their aliases must
normalize to unique, valid HTTP header names and cannot name transport-owned
headers such as
Content-Type,Content-Length, orHost; body media types belong torequest_media_type=. Header values must be safe ASCII without control characters or edge whitespace. - Nested path or query objects, multi-valued headers, null-only fields, and open-ended parameter mappings are rejected because they have no implicit wire encoding.
- Nullable query and header fields must be omittable, usually through a
Nonedefault. A required nullable field cannot distinguishNonefrom a missing parameter and is rejected. - Singular successful statuses must be between 200 and 399. Singular
204,205, and304outcomes must omitresponse=because those statuses cannot carry a body. - Text media types validate a string; other non-JSON media types validate
bytes or strings. An object model cannot be paired with
text/plain, and raw bytes cannot be a JSON response body. Charset-qualified text types are encoded and decoded strictly, and an unsupported declared charset fails when the contract is built. - JSON response bodies must satisfy their published serialization schema and be
readable by the declared type. Response field aliases must be readable by the
same model: use
Field(alias=...)or accept the serialization name invalidation_alias. See response constraints. - Before sending a typed request,
Clientpasses the concrete path, query, and header values through their HTTP encoding and the declared validator again, including configured httpx defaults, so an inherited parameter cannot silently replace an omitted value. A custom serializer must preserve both the scalar-or-array shape and the validated value. An empty repeated value is usable only when omission reconstructs the same default; otherwise the client fails before I/O without including the parameter payload in the error.