Advanced responses¶
By default a handler that returns a dict or model is serialised to a
JSONResponse, with status 200 and the
headers Veloce computes for you.
This page covers the levers for taking that control back:
- picking a different
Responseclass - documenting extra status codes in the OpenAPI schema
- setting cookies and headers with typed helpers
- changing the status code from inside the handler
For the basics of returning a response, see Requests and responses.
Choosing a response class¶
Pass response_class= to a route to control how the handler's return value is
encoded. The class is called with the return value, so a dict returned from this
handler is rendered as HTML-bytes by HTMLResponse rather than JSON:
from veloce import HTMLResponse, Veloce
app = Veloce()
@app.get("/page", response_class=HTMLResponse)
async def page():
return "<h1>Hello</h1>"
The default is JSONResponse. Returning a Response instance from the handler
always wins over response_class — the instance is sent as-is.
A default class for the whole app¶
Set default_response_class= on the app (or a Blueprint) to
change the fallback for every route that does not declare its own. A route-level
response_class= still overrides it:
from veloce import ORJSONResponse, Veloce
app = Veloce(default_response_class=ORJSONResponse)
@app.get("/items")
async def items():
return [{"id": 1}, {"id": 2}]
Note
ORJSONResponse is a semantic alias for JSONResponse (both encode with
orjson). Declaring it communicates the encoder choice; it does not change the
output bytes.
A custom JSON content type¶
Subclass JSONResponse and override default_media_type to ship a JSON suffix
type such as application/problem+json without re-implementing the encoder:
from veloce import JSONResponse
class ProblemJSON(JSONResponse):
default_media_type = "application/problem+json"
Use it as @app.get("/x", response_class=ProblemJSON). The class is also honoured
by ProblemJSON.from_bytes(...) when you already hold encoded JSON.
Documenting additional responses¶
A route advertises one success response in its OpenAPI document. Pass responses=
to document the other status codes the operation can return — a {status: spec}
mapping where each spec may carry a model (a Pydantic model for the response
body schema), a description, and any free-form OpenAPI keys (headers, links):
from pydantic import BaseModel
from veloce import Veloce, status
app = Veloce()
class Item(BaseModel):
id: int
name: str
class Error(BaseModel):
detail: str
@app.get(
"/items/{item_id}",
responses={
status.HTTP_404_NOT_FOUND: {"model": Error, "description": "Item not found"},
},
)
async def get_item(item_id: int) -> Item:
return Item(id=item_id, name="Widget")
The 404 now appears in the generated schema alongside the 200. The model
schema is emitted under application/json only — see the warning below.
responses= model schemas are JSON-only
A model entry generates its body schema under application/json. To
document a non-JSON media type, supply the OpenAPI content object yourself
as a free-form key instead of model:
Note
Set responses= on the app or a Blueprint to overlay the
same entries onto every route; a route's own responses= merges on top, and
per-route status codes win.
responses= only shapes the schema. It does not make the handler return those
statuses — that is the job of status_code=, HTTPException, or returning a
Response directly.
Status codes¶
Set the default success status with status_code=. The named constants in
status read better than bare integers:
from veloce import Veloce, status
app = Veloce()
@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item():
return {"id": 1}
The handler returns a plain dict and the response goes out as 201 Created.
Returning a different status per request¶
When the status depends on the result, return a response object with the status
you want. It overrides the route's status_code=:
from veloce import JSONResponse, Veloce, status
app = Veloce()
_items: dict[int, str] = {}
@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str):
created = item_id not in _items
_items[item_id] = name
code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
return JSONResponse({"id": item_id, "name": name}, status_code=code)
Dynamic status from an injected Response¶
Returning a JSONResponse bypasses the route's response_model filtering and
response_class. When you want the normal serialisation but still need to set the
status (or a header or cookie) conditionally, declare a response: Response parameter. Veloce injects a
fresh response object whose status and headers are merged onto the final one:
from veloce import Response, Veloce, status
app = Veloce()
_items: dict[int, str] = {}
@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str, response: Response):
if item_id not in _items:
response.status_code = status.HTTP_201_CREATED
_items[item_id] = name
return {"id": item_id, "name": name}
The returned dict is still serialised through the route's normal path; only the status you set is applied.
Note
The injected Response is created once per request and shared with any
dependency that also declares the parameter. Until you assign
response.status_code, it carries the sentinel 0, meaning "not set" — the
dispatcher leaves the real status untouched.
Headers¶
Set a one-off header through the injected response's headers dict, or use the
typed setters and properties on Response for
the standard fields:
from veloce import Response, Veloce
app = Veloce()
@app.get("/report")
async def report(response: Response):
response.headers["X-Report-Id"] = "42"
response.set_cache_control(max_age=3600, public=True)
return {"ok": True}
The typed helpers build correct values for you instead of hand-formatting header strings:
| Helper | Sets | Notes |
|---|---|---|
set_cache_control(...) |
Cache-Control |
Combines directives in RFC 9111 order. |
add_vary(*names) |
Vary |
Merges and de-duplicates case-insensitively. |
set_etag(value, weak=False) |
ETag |
Quotes the value; add_etag() derives one from the body. |
set_content_disposition(...) |
Content-Disposition |
Builds the RFC 6266 attachment/inline value. |
last_modified = ... |
Last-Modified |
Accepts datetime, Unix timestamp, or string. |
retry_after = ... |
Retry-After |
Accepts int, timedelta, or datetime. |
Conditional responses and Vary¶
add_vary tells caches which request
headers the response depends on, so a Vary: Cookie response is not served to a
different user. make_conditional
downgrades a response to 304 Not Modified when the request's If-None-Match or
If-Modified-Since preconditions already match (RFC 9110 §13):
from veloce import Response, Veloce
app = Veloce()
@app.get("/profile")
async def profile(request):
response = Response(body=b"<p>hi</p>", content_type="text/html")
response.add_vary("Cookie")
response.add_etag()
return response.make_conditional(request)
Cookies¶
Set cookies with set_cookie on the
injected response. The validated parameters build a correct RFC 6265 Set-Cookie
header, and multiple calls append rather than overwrite:
from veloce import Response, Veloce
app = Veloce()
@app.post("/login")
async def login(response: Response):
response.set_cookie("session", "abc123", httponly=True, samesite="Lax")
return {"ok": True}
samesite defaults to "Lax". Pass samesite="None" (with secure=True) for a
cross-site cookie, or samesite=None to omit the attribute entirely.
Match the attributes when deleting
A browser only replaces an existing cookie when Path, Domain, and the
Secure / SameSite / Partitioned attributes match. Pass the same flags to
delete_cookie that you used
to set it, or the cookie is stored twice instead of removed.
Partitioned cookies¶
Pass partitioned=True for a CHIPS cookie (Cookies Having Independent Partitioned
State), keyed to the top-level site so embedded third-party contexts each get an
isolated jar. Partitioned requires Secure, so it is only emitted when
secure=True:
__Host- and __Secure- prefixes¶
Pass prefix="host" or prefix="secure" to add the RFC 6265bis §4.1.3 name
prefix and enforce its invariants. "secure" requires secure=True; "host"
also requires path="/" and no domain. A violation raises ValueError:
The cookie travels on the wire as __Host-session.
Testing responses¶
Use the in-memory TestClient to assert the
status, headers, and cookies without a server:
from veloce import Response, TestClient, Veloce, status
app = Veloce()
_items: dict[int, str] = {}
@app.put("/items/{item_id}")
async def upsert(item_id: int, name: str, response: Response):
if item_id not in _items:
response.status_code = status.HTTP_201_CREATED
response.set_cookie("seen", "1")
_items[item_id] = name
return {"id": item_id, "name": name}
client = TestClient(app)
resp = client.put("/items/1?name=Widget")
assert resp.status_code == 201
assert resp.cookies["seen"] == "1"
resp = client.put("/items/1?name=Gadget")
assert resp.status_code == 200
Next steps¶
- Return values, status, and the response family basics — see Requests and responses.
- Shape and filter the response body with models — see Request models.
- Control how values are serialised to JSON — see JSON and encoding.
- Document operations and schemas — see OpenAPI.
- Full signatures are in the API reference.