Responses
A handler's return value is auto-shaped into the response the framework sends to the client. The auto-shaping covers the common JSON-API case; helpers exist for HTML, file downloads, and streaming when JSON isn't right.
The Request and Response objects
Handlers receive a rich gebweb.Request (declare a parameter typed
gebweb.Request) and the controller response builders return a rich
Response. The Request exposes method(), path(), scheme(),
isSecure(), host(), clientIp(), clientCert(), header(name),
cookie(name), the typed query getters (query, queryInt, queryBool,
queryAll), isJson(), text(), json(), and framework context
(routeParam(name), routeParams(), locale, tenant, user,
csrfToken, cspNonce). Both
objects stay index-compatible (req["headers"], resp["status"]) for
migration.
import gebweb;
@Get("/whoami")
func whoami(gebweb.Request req): gebweb.Response {
return this.json({"ip": req.clientIp(), "agent": req.header("User-Agent")});
}
A handler may also build a response directly with the body-first builders
http.response(body, status = 200), http.jsonResponse(value, status = 200),
and http.redirect(url, status = 302). Response header names are canonicalized
(e.g. X-Request-ID is stored as X-Request-Id); read them case-insensitively
with resp.header(name).
Automatic JSON wrapping
Anything that isn't already a response-shaped dict is wrapped as a 200 JSON response:
nullbecomes{"status": 204, "body": ""}.dict<string, any>becomes{"status": 200, "headers": {"Content-Type": "application/json"}, "body": <dict>}.list<any>is wrapped the same way.- A class instance, string, or primitive is stringified via
json.stringifyand wrapped.
A dict that already has a "status" key is treated as a fully
pre-shaped response and passed through unchanged. This is the
contract every helper below relies on.
HTML responses
import gebweb;
@Get("/page")
func page(): dict<string, any> {
return gebweb.html("<h1>Hello</h1>");
}
@Get("/created")
func created(): dict<string, any> {
return gebweb.html("<p>ok</p>", 201, {"Cache-Control": "no-store"});
}
The second and third arguments are optional. Custom headers merge on
top of the default Content-Type: text/html; charset=utf-8.
File downloads
@Get("/download/{id}")
func download(string id): dict<string, any> {
return gebweb.file("./uploads/" + id + ".pdf",
{"attachment": true, "filename": "report.pdf"});
}
The file streams straight from disk (it is not read into memory), and
the server handles HEAD, Range requests (206 Partial Content),
If-None-Match/ETag, and If-Modified-Since (304 Not Modified),
and sets Content-Length automatically. When you set no ETag, the
server derives a default one from the file size and modification time so
If-None-Match conditional caching works without extra work. Content-Type
is inferred from the file extension via a small built-in map; pass
opts.contentType to override. opts.attachment = true adds
Content-Disposition: attachment, with the suggested filename from
opts.filename (defaulting to the file's base name). A missing file
returns 404.
gebweb.file serves whatever path you give it, so validate any
user-supplied path before passing it in. The marker it returns is an
opaque native value: a handler that returns attacker-controlled data as
its response cannot forge a file response.
The in-process TestClient materializes the whole body so tests can assert
on it, but does not evaluate Range or conditional headers; use a real
server to exercise those. A missing file returns 404 in the TestClient
too, matching the real server. A response-phase middleware that only adjusts
headers or status still applies to a streamed file, but one that replaces the
body (a deny, an error page, or negotiated content) overrides the file, which
then does not stream. Replacing the body with an empty string counts as a
replacement, so an explicit withBody("") suppresses the file.
The compress middleware does not gzip a streamed file (its body is empty
during the middleware stage), so a large static asset is served
uncompressed. Pre-compress such assets or front them with a CDN if on-the-
wire compression matters.
Streaming responses
gebweb.stream(handler, opts?) returns a response dict the lower-level
HTTP server recognises as a streaming response. The handler runs after
status + headers are flushed and gets a raw stream handle:
import http;
@Get("/events")
func events(): dict<string, any> {
return gebweb.stream(func(int h): void {
for (i in 1..6) {
http.streamWrite(h, "tick " + (i as string) + "\n");
http.streamFlush(h);
time.sleep(1.0);
}
http.streamClose(h);
});
}
For Server-Sent Events specifically, @Sse is more ergonomic - see
WebSockets and SSE.
Custom responses
The auto-wrapping behaviour is built on a single contract: any dict
your handler returns that already has a "status" key is treated
as a fully pre-shaped response and passed through untouched. That
means every status code, content type, cookie, and header
combination is reachable by returning the dict directly:
@Post("/teapots")
func brew(): dict<string, any> {
return {
"status": 418,
"headers": {"Content-Type": "text/plain"},
"body": "I'm a teapot",
};
}
@Post("/users")
func create(UserDto body): dict<string, any> {
let user = repo.insert(body);
return {
"status": 201,
"headers": {"Content-Type": "application/json",
"Location": "/users/" + user.id},
"body": json.stringify(user),
};
}
The minimum shape is {"status": N, "body": <string or bytes>}.
The "headers" dict is optional; a missing or empty "body" is
treated as the empty string.
Setting cookies
Cookies are headers under the hood. Build a Set-Cookie string
and pass it through "headers":
return {
"status": 200,
"headers": {"Set-Cookie": "session=abc; HttpOnly; Path=/; Max-Age=3600"},
"body": json.stringify({"ok": true}),
};
For session-backed cookies, prefer the session store API in the Authentication chapter rather than hand-rolling the cookie.
Adding headers to a helper response
The helpers return the same dict shape, so you can mutate the result before returning it:
let r = gebweb.html("<p>ok</p>", 200);
(r["headers"] as dict<string, any>)["Cache-Control"] = "no-store";
return r;
HTTP exceptions
Throwing one of the framework's HTTP-shaped exceptions short-circuits the response with an RFC 9457 Problem Details body at the matching status:
@Get("/users/{id}")
func get(string id): dict<string, any> {
if (id == "") {
throw gebweb.badRequest("id is required");
}
throw gebweb.notFound("no user with id " + id);
}
The shipped factories cover the eight most common cases. For any
status code outside that set, construct an HttpException
directly:
import gebweb.errors as errors;
throw errors.HttpException(418, "I'm a teapot", "Teapot");
throw errors.HttpException(429, "rate limit exceeded", "Too Many Requests");
throw errors.HttpException(503, "database is down", "Service Unavailable");
HttpException(status, detail, title) is the underlying
constructor; the framework's Problem Details renderer formats it
the same way it formats the factory-built exceptions.
The factory functions all return objects that extend
gebweb.errors.HttpException. Use them for catch dispatch:
import gebweb.errors as errors;
try {
/* ... */
} catch (errors.HttpException e) {
/* ... */
}
Inside a controller class
Controllers that extend gebweb.Controller gain instance methods
that wrap the same primitives more ergonomically:
class UserController extends gebweb.Controller {
@Get("/users/{id}")
func get(string id): dict<string, any> {
if (id == "") { this.badRequest("id is required"); }
let u = this.repo.find(id);
if (u == null) { this.notFound("no user with id " + id); }
return u as dict<string, any>;
}
@Post("/login")
func login(LoginDto body): dict<string, any> {
if (auth.verify(body)) {
return this.redirect("/dashboard");
}
return this.redirect("/login?error=1", 303);
}
}
The methods (this.badRequest, this.notFound, etc.) throw the
matching exception so the handler body reads as straight-line
code. this.redirect(location, status?), this.back(request),
this.view(request, name, ctx), this.partial(request, name),
this.flash(...), and this.redirectWithFlash(...) are the
remaining surfaces; see the
Controller base class below.
Content negotiation with @Produces
@Produces declares that a handler can serve its data in more than one format.
The framework picks the best match from the client's Accept header and
serializes accordingly.
import gebweb;
class ReportController extends gebweb.Controller {
@Get("/reports/sales")
@Produces("json", "csv", "xml")
func sales(): list<dict<string, any>> {
return [
{"region": "North", "units": 120, "revenue": 48000},
{"region": "South", "units": 95, "revenue": 38000},
];
}
}
A GET /reports/sales with Accept: text/csv gets a CSV body; with
Accept: application/json it gets JSON; with Accept: application/xml it gets
XML. Curl examples:
curl -H "Accept: application/json" /reports/sales
curl -H "Accept: text/csv" /reports/sales
curl -H "Accept: application/xml" /reports/sales
Decorator syntax
Both a short name and the full MIME type are accepted:
@Produces("json", "csv", "xml")
@Produces("application/json", "text/csv", "application/xml")
The supported short names are:
| Short name | MIME type |
|---|---|
json |
application/json |
csv |
text/csv |
xml |
application/xml |
An unrecognized identifier is a startup-time error.
Negotiation rules
- Parse the
Acceptheader into (media-range, q) pairs (default q=1.0). The declared format with the highest matching q-value wins; ties are broken by the order they are declared in@Produces. A format withq=0is excluded. - If no
Acceptheader is present, or it resolves only to*/*with a positive q-value, the FIRST format declared is used (the handler's default). - If the
Acceptheader is present but none of the declared formats matches (including when the only header is*/*;q=0), the framework returns406 Not Acceptable(problem+json listing the produced types).
Applies only to raw data
@Produces applies when the handler returns raw data (a dict or list). If the
handler returns an http.Response (built via this.json(...),
this.html(...), or any other response builder), that response is used as-is
and @Produces is skipped.
Serialization
JSON -- json.stringify(data); Content-Type application/json.
CSV -- csv.stringify(rows); Content-Type text/csv. The data must be
tabular: a list of flat row dicts. A non-tabular shape (a single dict, or rows
containing nested structures csv.stringify cannot flatten) yields a
500 problem+json with the message "cannot serialize to CSV: expected a list
of rows".
XML -- xml.stringify(data); Content-Type application/xml. A dict
becomes a <response> element with one child element per key; a list becomes
<items> containing <item> children; nested values recurse.
@Groups is honored across all formats
If the handler also carries a @Groups decorator, the field filter is applied
before encoding regardless of the chosen format. Fields excluded by the group
set are absent from JSON, CSV, and XML output alike.
@Get("/users/{id}/summary")
@Produces("json", "csv")
@SerializeWith(["summary"])
func summary(string id): User {
return repo.find(id);
}
Reference
Response helpers
| Helper | Returns | Notes |
|---|---|---|
gebweb.html(body, status?, headers?) |
dict | Default status 200, Content-Type text/html; charset=utf-8. |
gebweb.htmlView(app, request, name, ctx, status?, headers?) |
dict | Renders a template via the registered view engine; threads request through the view-context injectors. |
gebweb.file(path, opts?) |
dict | opts.contentType, opts.attachment, opts.filename, opts.status, opts.headers. |
gebweb.stream(handler, opts?) |
dict | opts.contentType, opts.headers, opts.status. Handler takes a stream handle (int). |
Raw dict {"status": N, "headers": {...}, "body": "..."} |
dict | Any status / content type. The lowest-level contract every helper compiles to. |
HTTP exception factories
All return a class extending errors.HttpException; throwing
short-circuits with a Problem Details body at the matching status.
| Factory | Status |
|---|---|
gebweb.badRequest(detail) |
400 |
gebweb.unauthorized(detail) |
401 |
gebweb.forbidden(detail) |
403 |
gebweb.notFound(detail) |
404 |
gebweb.conflict(detail) |
409 |
gebweb.unprocessableEntity(detail, errors) |
422 |
gebweb.internalServerError(detail) |
500 |
errors.HttpException(status, detail, title) |
any |
Controller base class summary
gebweb.Controller methods that build a response or throw an
exception:
| Method | Returns / throws |
|---|---|
this.json(data, status = 200) |
JSON response dict. |
this.text(body, status = 200) |
Plain-text response dict (Content-Type: text/plain). |
this.html(body, status = 200, headers = null) |
Same as gebweb.html(...). |
this.created(data, location) |
201 with Location header and JSON body. |
this.accepted(data = null) |
202; body is JSON-encoded data or empty. |
this.stream(handler, opts = null) |
Streaming response dict. |
this.redirect(location, status = 302) |
3xx redirect dict. |
this.back(request, fallback = "/") |
303 redirect to Referer or fallback. |
this.view(request, name, ctx, status = 200) |
HTML rendered from the view engine. |
this.partial(request, name, ctx) |
HTML fragment with Vary: HX-Request. |
this.flash(request, response, category, message) |
Returns response with a session flash attached. |
this.redirectWithFlash(request, location, category, message) |
303 redirect + flash. |
this.badRequest(detail) / this.unauthorized(detail) / this.forbidden(detail) / this.notFound(detail) / this.conflict(detail) / this.unprocessable(detail, errs) |
Throws the matching HttpException. |
this.problem(status, title, detail, extras = null) |
Builds a Problem Details response at any status without throwing. |