Responses
A handler returns a spider.Response. c builds it: every helper takes the content and an options struct, and .{} means status 200 with no extra headers.
Where it can, this page shows code from Posts, the example app in examples/posts (source).
JSON
c.json takes any struct, including one written in place, and turns it into JSON. The API of Posts answers one post like this:
/// GET /api/posts/:id. The id arrives as a number: a value that is not
/// one answers 400 before this function runs.
pub fn show(id: spider.Path(i64, "id"), c: *spider.Ctx) !Response {
const post = (try repository.find(c.arena, id.value)) orelse return error.NotFound;
return c.json(asJson(post), .{});
}
fn asJson(post: model.Post) PostJson {
return .{ .id = post.id, .title = post.title, .body = post.body, .author = post.author };
}
/// A post as the API shows it.
const PostJson = struct {
id: i64,
title: []const u8,
body: []const u8,
author: []const u8,
};
Status and headers
The options set the status and add headers. After saving a post the API answers 201, with the address of the new post in Location:
/// POST /api/posts, with the post as JSON in the body.
pub fn create(c: *spider.Ctx) !Response {
const input = (try c.bodyJson(model.Input)).trimmed();
const errors = model.validate(input);
if (errors.any()) return c.json(.{ .errors = errors }, .{ .status = .unprocessable_entity });
// A program holds the key, not an account: the post belongs to nobody.
const id = try repository.create(c.arena, input, null);
const location = try std.fmt.allocPrint(c.arena, "/api/posts/{d}", .{id});
return c.json(.{ .id = id }, .{
.status = .created,
.headers = try c.arena.dupe([2][]const u8, &.{.{ "Location", location }}),
});
}
.status is a std.http.Status: .created, .no_content, .not_found and so on. Headers are a slice of name and value pairs; one built while the request runs comes from c.arena, as here, so it is still there when the response is sent.
Text and HTML
c.text and c.html answer a string as it is:
fn hello(c: *spider.Ctx) !spider.Response {
return c.text("Hello", .{});
}
fn banner(c: *spider.Ctx) !spider.Response {
return c.html("<h1>Hello</h1>", .{});
}
Redirects
c.redirect answers 302 with the address in Location. Posts goes back to the feed after deleting a post:
pub fn delete(c: *spider.Ctx, mine: Mine) !Response {
const post = mine.value;
try repository.delete(c.arena, post.id);
return c.redirect("/posts");
}
After a form is saved, use c.redirectWith. It answers 303, which tells the browser to load the next page with a GET, and its options can carry headers and cookies. Posts goes to the post it just saved:
/// After a save: go to the post. 303 makes the browser load it with a GET.
fn saved(c: *spider.Ctx, id: i64) !Response {
return c.redirectWith(try std.fmt.allocPrint(c.arena, "/posts/{d}", .{id}), .{});
}
Errors
A handler that returns an error ends the request with the matching status: error.NotFound is 404, error.Unauthorized 401, error.Forbidden 403 and error.BadRequest 400. Any other error is 500.
Posts returns error.NotFound for a post that does not exist:
pub fn show(c: *spider.Ctx) !Response {
const post = (try policy.load(c)) orelse return error.NotFound;
return c.view("posts/show", .{
.title = post.title,
.post = try present.card(c.arena, post),
// The same rule the routes use decides whether the buttons show.
.can_edit = try Posts.can(c, .update, &post),
// The body is Markdown. Spider's renderer escapes what the author
// typed, so its HTML can go into the page as it is.
.body = spider.RawHtml{ .html = try spider.zmd.parse(c.arena, post.body, .{}) },
}, .{});
}
What the visitor then sees is decided in one place, the error handler given to onError in src/main.zig. Posts shows its own pages for a 404 and a 403, and leaves the rest to Spider:
/// Where every error a handler or a middleware returns ends up. The API
/// answers in JSON; a missing post or page gets the 404 page, a post that
/// is someone else's the 403 page; anything else gets Spider's own answer.
fn onError(c: *spider.Ctx, err: anyerror) anyerror!spider.Response {
if (std.mem.startsWith(u8, c.getPath(), "/api/")) return api_errors(c, err);
if (err == error.NotFound) {
return c.view("not-found", .{ .title = "Page not found" }, .{ .status = .not_found });
}
return fallback(c, err);
}
const fallback = spider.errorHandler(.{
.unauthorized_redirect = "/auth/login",
.forbidden_view = "forbidden",
});
const api_errors = spider.errorHandler(.{ .always_json = true });
To say more about a 400, set the detail before returning the error, as the API does for a ?limit= that is not a number:
/// GET /api/posts: every post, newest first. `?q=` searches, `?limit=`
/// cuts the list.
pub fn list(c: *spider.Ctx) !Response {
const q = c.queryDecoded("q") orelse "";
const posts = if (q.len > 0)
try repository.search(c.arena, q)
else
try repository.list(c.arena);
var limit = posts.len;
if (c.query("limit")) |text| {
limit = std.fmt.parseInt(usize, text, 10) catch {
c.setErrorDetail("limit must be a whole number");
return error.BadRequest;
};
}
const shown = posts[0..@min(limit, posts.len)];
const out = try c.arena.alloc(PostJson, shown.len);
for (shown, out) |post, *json| json.* = asJson(post);
return c.json(.{ .posts = out }, .{});
}
To choose the whole body yourself, return a response with the status, as create above does with its 422. In Zig, a JSON key named error is written .@"error".
Templates
c.view renders a template with the data you give it, and its options set the status like any other response. Posts shows its form again with 422 when a field is missing:
/// The form for a new post (`id` is null) or for an existing one. With
/// errors it answers 422 and keeps what was typed.
fn form(c: *spider.Ctx, id: ?i64, input: model.Input, errors: model.Errors) !Response {
const heading: []const u8 = if (id == null) "New post" else "Edit post";
return c.view("posts/form", .{
.title = heading,
.heading = heading,
.action = if (id) |n| try std.fmt.allocPrint(c.arena, "/posts/{d}/update", .{n}) else "/posts/create",
.back = if (id) |n| try std.fmt.allocPrint(c.arena, "/posts/{d}", .{n}) else "/posts",
.back_label = if (id == null) "All posts" else "Back to the post",
.submit = if (id == null) "Publish post" else "Save changes",
.form = input,
.errors = errors,
.composing = true,
}, .{ .status = if (errors.any()) .unprocessable_entity else .ok });
}
Templates explains the files.
Cookies
c.withCookie gives back the options with a Set-Cookie header in them. A cookie is HttpOnly, Secure and SameSite=Lax unless you say otherwise.
A cookie cannot hold every character. For text a person typed, pass .encode = true, and read the cookie back with c.cookieDecoded.
When someone signs in, Posts sends two cookies with its redirect: the session, and the email to have typed next time.
/// Starts the session, remembers the email for the next sign-in on this
/// browser, and goes to the home page. Two cookies: the options of both go
/// in the same response.
fn signIn(c: *spider.Ctx, user: model.User) !Response {
const session = try spider.session.start(c, .{
.id = try std.fmt.allocPrint(c.arena, "{d}", .{user.id}),
.email = user.email,
.name = user.name,
.roles = &.{user.role},
});
// `.encode`: an email is text a person typed, not safe for a cookie as it is.
const remembered = try c.withCookie(last_email, user.email, .{ .encode = true, .max_age = one_year });
return c.redirectWith("/", .{
.headers = try std.mem.concat(c.arena, [2][]const u8, &.{ session.headers, remembered.headers }),
});
}
const last_email = "last_email";
const one_year = 365 * 24 * 60 * 60;
Files
c.download sends bytes as a file the browser saves. The file name may come from user data: it is made safe before it is sent. Posts exports its posts as a CSV file:
/// Every post as a spreadsheet file the browser saves.
pub fn exportCsv(c: *spider.Ctx) !Response {
const posts = try repository.list(c.arena);
var csv: std.ArrayList(u8) = .empty;
try csv.appendSlice(c.arena, "id,title,author\n");
for (posts) |post| {
try csv.print(c.arena, "{d},{s},{s}\n", .{
post.id,
try csvField(c.arena, post.title),
try csvField(c.arena, post.author),
});
}
return c.download(csv.items, .{
.filename = "posts.csv",
.content_type = spider.content_types.csv,
});
}