Actions#
An action handles a request and returns a zyn.Response:
fn action(action: *zyn.Action) !zyn.Response
| Task | Capability |
|---|---|
| Read input | param, query, headers, expect_payload |
| Inspect the request | request_id, method, method_string, url, formats |
| Check access | Route guards, auth, authorization |
| Return output | json, render, text, redirect |
| Share request state | locals, arena, session, flash, cookies |
| Use application services | db, jobs, notifications, storage, logger, io |
Read input#
Typed JSON and form payloads#
Use zyn.Action.expect_payload through try action.expect_payload(T) when an endpoint requires a known value:
const Payload = struct {
name: []const u8,
nickname: ?[]const u8 = null,
};
const payload = try action.expect_payload(Payload);
return action.json(.{
.name = payload.name,
.nickname = payload.nickname,
}, .{});
The same action accepts a JSON request such as:
{
"name": "Ari",
"nickname": "ari"
}
Optional fields and defaults belong on T. Decoded values live for the request, so callers do not free them. Types without a custom parser accept JSON objects or URL-encoded forms.
Keep an action-specific input as a private Payload type beside its action. Put reusable public resource or media-type payloads, including their custom parsers, under src/resources.
Path, query, and header fields#
Path parameter lookup requires a name declared by the matched route. Header lookup returns null when a value is absent. Query parsing is fallible and returns a field collection with get and iterator:
fn show(action: *zyn.Action) !zyn.Response {
const query = try action.query();
return action.json(.{
.id = action.param("id"),
.preferred_format = if (action.formats.len > 0)
action.formats[0].name()
else
null,
.include = query.get("include"),
.request_id = action.request_id,
}, .{});
}
Request header names are matched ASCII case-insensitively. Path parameters come only from matched route segments; query fields never mix into path parameters or body payloads. For repeated query names, get returns the first value while iterator retains every field in wire order.
Authorize requests#
For browser sessions, protect routes with .guard(zyn.auth.signed_in) and use action.auth for the current identity. Authentication does not authorize every record: scope database queries to the current user or add a resource-specific guard. See Authentication and Routing.
For Basic or Bearer authentication, parse the Authorization field and verify the credentials before granting access. Parsing a scheme alone is not authentication.
Authorization fields#
try action.authorization() parses the unique Authorization request field:
switch (try action.authorization()) {
.missing, .invalid => return .challenge(.basic, .{ .realm = "admin" }),
.basic => |basic| try authenticate_password(basic.username, basic.password),
.bearer => |token| try authenticate_token(token),
.other => return error.UnsupportedAuthorization,
}
The result distinguishes missing, invalid, Basic, Bearer, and unrecognized schemes. Basic credentials are decoded into the request arena, split at the first colon, rejected on control bytes or invalid UTF-8, and never logged by zyn. Bearer and other credentials remain opaque. Duplicate or malformed fields produce .invalid; allocation failure propagates.
Authentication challenges#
Typed authentication challenges return a canonical 401 Unauthorized:
return .challenge(.basic, .{
.realm = "admin",
.charset = .utf8,
});
Actions return them directly. Hooks use respond when they need to stop before the action. This example checks only the scheme; verify credentials before granting access:
fn require_basic(hook: *zyn.Hook) !void {
switch (try hook.authorization()) {
.basic => try hook.next(),
else => try hook.respond(try .challenge(.basic, .{
.realm = "admin",
.charset = .utf8,
})),
}
}
Basic requires a realm and can advertise UTF-8. Bearer accepts realm, scope, error_code, error_description, and error_uri. Zyn validates, quotes, and escapes challenge parameters for WWW-Authenticate. The outcome owns its options until the framework consumes it. Guards use the same serialization through .challenge(scheme, options).
Return a response#
Complete responses are zyn.Response values:
fn create(action: *zyn.Action) !zyn.Response {
try action.headers.set("X-Request-ID", "request-123");
return .text("Created", .{ .status = .created });
}
Actions and hooks provide empty, text, html, xml, markdown, ics, csv, svg, bytes, redirect, and challenge alongside json and render. These helpers copy bodies and metadata into request memory before returning .complete. Temporary callback buffers are safe, and allocation failures and response limits propagate to the caller. Prefer these helpers in request code:
return action.text("Created", .{ .status = .created });
The corresponding zyn.Response value constructors remain available when no request role is available. They copy and own both bodies and metadata using the process allocator. Use inferred constructors such as .text(...) when the expected type is already zyn.Response. Returning a value or passing it to respond transfers ownership to the framework; otherwise call deinit exactly once. The framework copies the outcome into request memory before releasing it. Both construction and application can return error.OutOfMemory.
JSON and views#
JSON and views allocate or perform eager work, so their helpers live on the action and return a completed response:
return action.json(.{
.id = "article-123",
.published = false,
}, .{ .status = .created });
return action.render("articles/show.html", .{
.title = "Article details",
}, .{});
Configured HTML or XML layouts wrap views of the same format. JSON uses compact serialization and the JSON content type.
Headers#
action.headers is a zyn.Headers capability. get(name) reads an inbound request field, while set(name, value) and remove(name) change outbound response fields:
const accepted = action.headers.get("accept") orelse "*/*";
try action.headers.set("Cache-Control", "private, max-age=60");
Names match case-insensitively. Replacing Vary merges and deduplicates its tokens. An outbound field written through set is not returned by get; request and response fields remain distinct even though one capability exposes their direction-specific operations. Outbound names must be HTTP token names. Outbound values reject control characters other than horizontal tab, including carriage return and line feed, before the response is changed.
Select a response format#
A final extension selects the sole response format without becoming part of a bare parameter. /articles/42.json, for example, gives parameter "42" and format .json. Without an extension, action.formats follows the request's accepted formats in preference order. A constrained parameter such as /events/:event_id.ics matches only that suffix.
Try each acceptable format until the action finds a representation it supports. Return HTTP 406 Not Acceptable only after every candidate is exhausted:
fn show(action: *zyn.Action) !zyn.Response {
for (action.formats) |format| switch (format) {
.html => return .html("<h1>zyn</h1>", .{}),
.markdown => return .markdown("# zyn\n", .{}),
.json => return try action.json(.{ .name = "zyn" }, .{}),
else => continue,
};
return .text("Not Acceptable", .{ .status = .not_acceptable });
}
Request state#
Request-scoped application data#
Define the shape of action.locals in src/app.zig:
pub const Locals = struct {
user_id: ?[]const u8 = null,
};
Global middleware, guards, hooks, and actions can then share typed request-scoped state through action.locals. Use action.arena for allocations that should live until the request finishes and action.io for Zig I/O operations.
The application defines typed encrypted browser state through app.Session. Actions mutate it through action.session; see Application state in framework/application for its limits and security rules.
Flash messages#
Queue a typed entry through action.flash.add:
try action.flash.add(.success, "Project created.");
return .redirect(.see_other, "/projects");
action.flash.messages() returns the ordered []const app.Flash available to the current response flow. The application-owned entry shape, capacity, persistence, and consumption lifecycle are documented under Application state in framework/application.
Cookies#
action.cookies reads inbound cookies and writes outbound cookies. Plain, signed, and encrypted cookies use the same operations:
const theme = try action.cookies.get("theme");
try action.cookies.set("theme", "dark", .{});
Use the .signed capability to authenticate a value or .encrypted to decrypt and authenticate it:
const user_id = try action.cookies.signed.get("user_id");
const session = try action.cookies.encrypted.get("session");
try action.cookies.signed.set("user_id", id, .{
.lifetime = .permanent,
});
try action.cookies.encrypted.delete("session", .{});
Protected cookie values are created by the matching response-cookie API. Invalid, malformed, or tampered protected cookies return null. Signed and encrypted cookies both provide integrity; only encrypted cookies provide confidentiality. Both require the selected credential profile key to contain exactly 32 unpredictable random bytes encoded as 64 lowercase hexadecimal characters. Zyn rejects invalid key material and derives separate keys for each purpose using Zig standard-library cryptography.
Cookie write defaults are:
- session lifetime, path
/, no domain, andSameSite=Lax; HttpOnlyfor signed and encrypted values;Securefor signed and encrypted values over HTTPS.
Use .permanent for a forty-year maximum age or .{ .max_age_seconds = seconds } for an exact positive duration. Invalid lifetimes return error.InvalidCookieLifetime. Zyn validates names, values, paths, and domains; SameSite=None requires Secure.
delete is the only operation which expires a cookie. Its options accept the matching path, domain, and optional secure override. A cookie written with a non-default path or domain must be deleted with that same scope.
Handle failures#
Catch expected application errors and return an explicit response:
const post = load_post(action, action.param("id")) catch |err| switch (err) {
error.NotFound => return .text("Not Found", .{ .status = .not_found }),
else => return err,
};
An uncaught error.InvalidRequestPayload becomes the bounded JSON 400 described under Invalid payload diagnostics unless the application replaces its representation. Any other error propagated by an action, hook, middleware, or response operation becomes 500 Internal Server Error; the error value is retained for server diagnostics and is never included in the response body.
Use the handle_error callback described under Error response reference to replace framework-generated error representations before output is committed.
Payload reference#
Media types#
The optional action.content_type, hook.content_type, and guard.content_type fields expose the parsed inbound Content-Type as a zyn.MediaType; middleware uses middleware.request.content_type. A malformed field is rejected with HTTP 400 before application code runs. is compares the type and subtype case-insensitively while ignoring parameters, has_suffix explicitly matches a structured syntax suffix after +, and raw returns the original field value:
if (action.content_type) |content_type| {
if (content_type.is("application/json") or
content_type.has_suffix("json"))
{
// Handle a JSON representation.
}
}
Raw request bodies#
action.body() returns the raw request bytes, or null when no body is present. Use it when the action deliberately handles uninterpreted bytes without a typed payload parser:
fn echo(action: *zyn.Action) !zyn.Response {
return .text(action.body() orelse "", .{});
}
Dynamic payloads#
try action.payload() parses an optional application/json object or application/x-www-form-urlencoded form into a std.json.ObjectMap. It returns null for an absent body or unsupported content type:
const payload = (try action.payload()) orelse {
return .text("A payload is required", .{ .status = .bad_request });
};
const name_value = payload.get("name") orelse {
return .text("name is required", .{ .status = .unprocessable_entity });
};
const name = switch (name_value) {
.string => |value| value,
else => return .text(
"name must be a string",
.{ .status = .unprocessable_entity },
),
};
return action.json(.{ .name = name }, .{});
Use the dynamic API when fields are intentionally open-ended. A valid JSON array, scalar, or null body returns null because this API normalizes only objects. Malformed supported payloads return error.InvalidRequestPayload.
Custom payload parsers#
For XML, iCalendar, or an application-specific wire format, a payload type can own decoding by declaring this exact hook:
const Payload = struct {
message: []const u8,
pub fn parse(payload: *zyn.Payload) zyn.PayloadError!@This() {
if (payload.body.len == 0) try payload.invalidate(.{
.path = "/message",
.reason = "required",
});
return .{ .message = payload.body };
}
pub fn for_logging(
self: *const @This(),
arena: std.mem.Allocator,
) std.mem.Allocator.Error!std.json.ObjectMap {
var object: std.json.ObjectMap = .empty;
try object.put(arena, "message", .{ .string = self.message });
return object;
}
};
Keep this type beside its action when it is action-specific. When it is a reusable resource or media-type representation, expose it with its parser from the resource's TitleCase module under src/resources.
zyn.Payload supplies the request arena, raw body, parsed optional content_type, and read-only headers. Body and header slices already live for the request; allocate only decoded or transformed values. payload.invalidate records a bounded issue, allowing the parser to report several problems before zyn rejects the result. Reasons are application-owned lowercase snake-case strings.
A custom parser owns its media-type policy. It may inspect payload.content_type; Zyn does not apply that policy from route extensions or response formats and does not fall back to JSON or form decoding.
The optional for_logging hook projects a successfully parsed value into the request arena for RequestLogger. Treat it as a security boundary: omit fields that must never be logged, then use the logger's filtering policy as an extra safeguard. Allocation failure omits the projection without failing the request.
Invalid payload diagnostics#
Built-in typed payloads reject absent or unsupported bodies, unknown fields, missing required fields, and invalid values. Custom parsers can report their own issues with invalidate. When error.InvalidRequestPayload propagates, zyn returns a bounded machine-readable HTTP 400 response:
{
"error": "invalid_payload",
"details": [
{
"path": "/name",
"reason": "required"
},
{
"path": "/admin",
"reason": "unknown"
}
]
}
action.invalid_payload_details() exposes the same request-local diagnostics when an action wants to catch the error and produce a different application response.
Response reference#
Complete responses#
Typed body constructors take the body first and zyn.Response.Options last. The status defaults to 200 OK; etag and Unix-microsecond last_modified validators are optional:
return .html("<strong>zyn</strong>", .{});
return .xml("<multistatus/>", .{ .status = .multi_status });
return .csv("name,version\nzyn,1\n", .{});
return .svg("<svg xmlns=\"http://www.w3.org/2000/svg\"/>", .{});
return .bytes("binary\x00data", .{});
return .empty(.{ .status = .no_content });
return .redirect(.see_other, "/articles/created");
The fixed body constructors set exact content types. csv uses text/csv; charset=utf-8 and svg uses image/svg+xml. bytes sends an already-encoded byte slice unchanged and defaults to application/octet-stream. empty and redirect have no representation body. A redirect owns its Location value as soon as the outcome is constructed.
Use bytes with zyn.Response.BytesOptions for an already-encoded representation whose content type has no named helper:
return .bytes("framework: zyn\n", .{
.content_type = "application/yaml",
.status = .created,
});
BytesOptions has the same status, etag, and last_modified fields as Options and adds a content_type field which defaults to application/octet-stream. The outcome owns its body bytes and any nondefault content type.
Buffered output#
Use action.writer(options) for incremental output which remains buffered:
const writer = (try action.writer(.{
.content_type = "text/plain; charset=utf-8",
})).?;
try writer.writeAll("first line\n");
try writer.print("item count: {d}\n", .{items.len});
return .complete;
WriterOptions accepts status, etag, Unix-microsecond last_modified, and content_type. The writer is null when request preconditions have already completed a 304 or 412.
Completed bodies produced by response constructors, JSON, views, layouts, or this writer may not exceed httpz.response.max_body_size, which defaults to 16 MiB. Constructors and renderers return error.ResponseBodyTooLarge; writer calls expose the standard writer error.WriteFailed, which zyn translates to error.ResponseBodyTooLarge at the request boundary. All writers in one response pipeline share this byte limit.
Streaming#
Raw operations are the explicit escape hatch for output which must reach the client before the action returns:
try action.headers.set("Content-Type", "text/plain; charset=utf-8");
try action.flash.add(.info, "Streaming started.");
try action.raw().chunk("first\n");
try action.raw().chunk("second\n");
try action.raw().write();
return .complete;
raw() also supports write_header, disown, start_event_stream, and start_event_stream_sync. After committing raw output, do not use buffered response APIs or mutate session or flash state. The completed-response body limit does not cap these streaming APIs; applications should apply their own stream byte and lifetime policy. Once a raw operation completes or transfers response ownership, the response finalizer does not reapply the buffered-body limit to stale response fields.
Conditional requests#
A selected representation is described by zyn.Validators:
const current: zyn.Validators = .{
.etag = .{ .strong = "article-v3" },
.last_modified = article.updated_at,
};
last_modified is a Unix timestamp in microseconds. Zyn compares HTTP dates at whole-second precision, clamps future values to the response time, and emits canonical Last-Modified fields. Entity-tag opaque values are supplied without wire-format quotes.
Complete response options emit validators and automatically apply request preconditions for successful GET and HEAD responses before JSON serialization, template rendering, file I/O, or writer creation:
return action.json(article, .{
.etag = current.etag,
.last_modified = current.last_modified,
});
Use action.apply_preconditions before an unsafe operation or before expensive GET or HEAD work. A returned response is already completed as 304 Not Modified or 412 Precondition Failed; null means processing may continue:
if (try action.apply_preconditions(current)) |response| return response;
const updated = try update_article(action.db, article);
return action.json(updated, .{
.etag = .{ .strong = updated.version },
.last_modified = updated.updated_at,
});
Passing null means no selected representation exists, enabling create-only requests guarded by If-None-Match: *. zyn.Validators{} means a representation exists without known validators.
Zyn evaluates If-Match, If-Unmodified-Since, If-None-Match, and If-Modified-Since in protocol order. If-Match uses strong entity-tag comparison; If-None-Match uses weak comparison. A matching GET or HEAD cache condition returns 304; a failed precondition otherwise returns 412. Invalid date fields are ignored. Applications must keep a successful precondition check and its mutation atomic with a transaction, lock, or compare-and-swap.
Responses to HEAD never send body bytes. The action still observes HEAD and prepares the same status and headers as GET; zyn retains a known representation length while suppressing direct, buffered, and streamed body output. Raw responses set wire-format validator fields through action.headers.
Application boundary#
zyn.Response is the outcome returned by actions and supplied to hook.respond. It is not a mutable transport object. zyn.http.Response is the distinct live response exposed to middleware and framework integrations through middleware.response. Return .complete after action.writer or action.raw operations have already prepared the response.
Error response reference#
Applications may export handle_error from src/app.zig. This example uses the HTML error views generated by zyn init and supplies JSON for 404 and 500:
const zyn = @import("zyn");
pub fn handle_error(problem: *zyn.HttpError) !?zyn.Response {
for (problem.formats) |format| switch (format) {
.html => switch (problem.status) {
.bad_request => return try problem.render("errors/400.html", .{}),
.not_found => return try problem.render("errors/404.html", .{}),
.internal_server_error => return try problem.render("errors/500.html", .{
.request_id = problem.request_id,
}),
else => {},
},
.json => switch (problem.status) {
.not_found => return try problem.json(.{ .code = "not_found" }),
.internal_server_error => return try problem.json(.{
.code = "internal_server_error",
}),
else => {},
},
else => {},
};
return null;
}
- When: Framework-generated 400, 401, 403, 404, 405, and 500 responses before bytes are committed. Explicit action or middleware responses and transport/parser failures bypass the callback.
- Input:
problem.statusis fixed,problem.sourceidentifies the failure category without exposing a Zig error, andproblem.formatsfollowsaction.formats. Request ID, method, and URL are also available. - Output: Use a representation helper such as
problem.renderorproblem.json. Matched-route layouts do not apply; status, applicable headers, body limits, and HEAD behavior remain in force. - Fallback: Return
nullto keep the original response. A failed renderer, redirect, or challenge also falls back; renderer failures are logged without another callback invocation.
Raw output cannot be replaced once committed. Do fallible work before action.raw() commits headers or bytes.