Zig 0.17.0

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:

zig
/// 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:

zig
/// 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:

zig
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:

zig
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:

zig
/// 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:

zig
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:

zig
/// 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:

zig
/// 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:

zig
/// 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.

zig
/// 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:

zig
/// 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,
    });
}