Requests
A handler receives c: *spider.Ctx. Everything the client sent is read through it: path parameters, the query string, headers, cookies and the body.
Where it can, this page shows code from Posts, the example app in examples/posts (source).
Path and query
c.params.get reads a :name segment of the route. Posts reads the :id of /posts/:id this way:
/// The post named by the :id of the path. Null (no such post, or an id
/// that is not a number) is the 404 page.
pub fn load(c: *spider.Ctx) !?model.Post {
const text = c.params.get("id") orelse return null;
const id = std.fmt.parseInt(i64, text, 10) catch return null;
return repository.find(c.arena, id);
}
c.queryDecoded reads a value of the query string, decoded: what you want for text a person typed. The feed of Posts uses it for its search, /posts?q=router:
/// The feed. With ?q= it only lists the posts that match.
pub fn index(c: *spider.Ctx) !Response {
const q = std.mem.trim(u8, c.queryDecoded("q") orelse "", " ");
const posts = if (q.len > 0)
try repository.search(c.arena, q)
else
try repository.list(c.arena);
return c.view("posts/index", .{
.title = "Posts",
.q = q,
.searching = q.len > 0,
.posts = try present.cards(c.arena, posts),
}, .{});
}
c.query reads the value as it was sent, without decoding, which is fine for a number. All three return ?[]const u8: null when the value is not there. The API of Posts takes a ?limit=:
/// 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 }, .{});
}
Cookies
c.cookie gives the value of a cookie as the browser sent it. c.cookieDecoded also decodes it: use it for a cookie that was written with .encode, as Responses shows.
Posts remembers the email of the last sign-in on a browser, and has it typed when the sign-in form opens:
pub fn loginForm(c: *spider.Ctx) !Response {
// The email of the last sign-in on this browser, already typed.
return loginPage(c, c.cookieDecoded(last_email) orelse "", null);
}
Headers
c.header reads a request header. Names are matched without regard to case. The JSON API of Posts asks for a key in one:
/// Lets a request through only when it carries the API key in the
/// X-Api-Key header. Anything else ends here with 401: the handler never
/// runs.
pub fn requireKey(c: *spider.Ctx, next: spider.NextFn) anyerror!Response {
const sent = c.header("X-Api-Key") orelse return error.Unauthorized;
if (!std.mem.eql(u8, sent, apiKey())) return error.Unauthorized;
return next(c);
}
Returning error.Unauthorized answers 401. error.NotFound answers 404 and error.Forbidden 403.
JSON body
c.bodyJson parses the body into a struct. Fields the struct does not have are ignored; a field with a default value may be left out by the client. Posts takes a new post as JSON with the same struct its form uses:
/// 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 }}),
});
}
A body that is missing, or is not JSON that fits the struct, answers 400.
Forms
c.parseForm reads an HTML form, urlencoded or multipart, into a struct. A field can be text, a number, a bool, or an optional of those. This is what the form of a post sends:
/// What the form sends.
pub const Input = struct {
title: []const u8 = "",
body: []const u8 = "",
author: []const u8 = "",
/// The same input without the spaces around each field.
pub fn trimmed(self: Input) Input {
return .{
.title = std.mem.trim(u8, self.title, " \t\r\n"),
.body = std.mem.trim(u8, self.body, " \t\r\n"),
.author = std.mem.trim(u8, self.author, " \t\r\n"),
};
}
};
And the handler that receives it:
pub fn create(c: *spider.Ctx) !Response {
var input = (try c.parseForm(model.Input)).trimmed();
input.author = signedInName(c);
const errors = model.validate(input);
if (errors.any()) return form(c, null, input, errors);
const id = try repository.create(c.arena, input, policy.signedInId(c));
return saved(c, id);
}
A field the form did not send is an empty string, zero or false; an optional one is null. create checks the fields itself and shows the form again when one is missing. The form has no author field: the author is whoever is signed in.
Typed parameters
Instead of reading and converting a value yourself, a handler can ask for it in its parameters. spider.Path takes a path segment as a number or text, and spider.Form takes the form. A value that does not convert answers 400 before the handler runs.
/// 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), .{});
}
The value is in .value. The parameters can come in any order.