Repository navigation
JSON
The JSON module is a thin wrapper over std.json that knows about zap.Request. It doesn't implement a JSON parser - Zig's standard library already has a very good one. What it adds is three things:
- A single call to go from "request body" to "parsed struct", with the right error response sent on failure.
- A single call to go from "any value" to "response body".
- A consistent memory story - you supply an arena, you don't think about it again.
Everything else - the parse options, the stringify options, the error set - comes straight from std.json. If you need behavior this module doesn't expose, reach for std.json directly and skip the wrapper.
-
parseFromBody
Purpose: parse the request body as JSON into typeT, sending the appropriate error response on failure.
Signature:pub fn parseFromBody( comptime T: type, allocator: std.mem.Allocator, r: zap.Request, ) ?T
Situation Response sent Return Body is missing 400 Bad Request,"Missing JSON body"nullBody is not valid JSON 500 Internal Server Error,"Invalid parse JSON"nullSuccess - parsed TNote the asymmetry: a missing body is treated as a client error (
400), but malformed JSON is treated as a server error (500). If you want400for malformed JSON too, useparseFromSliceand handle the error yourself. -
parseFromSlice
Purpose: parse a JSON string intoTwithout touching the request. Errors propagate.
Signature:pub fn parseFromSlice( comptime T: type, allocator: std.mem.Allocator, s: []const u8, ) !T
This is the version to use when you want fine-grained control - inside tests, for parsing config files, for validating a request before committing to a response.
-
stringifyAlloc
Purpose: serialize a value to a JSON string. Caller owns the result.
Signature:pub fn stringifyAlloc( allocator: std.mem.Allocator, value: anytype, options: std.json.Stringify.Options, ) ![]u8
The returned buffer must be freed with the same allocator. When using an arena, you can skip the free -
deinit()handles it. -
send
Purpose: serialize a value and send it as a200 OKresponse, all in one call.
Signature:pub fn send( allocator: std.mem.Allocator, r: zap.Request, value: anytype, ) !void
Internally:
stringifyAlloc→ free the buffer →response.sendBody. The return type is!voidbecause serialization can fail. The send itself swallows its errors, as always.
std.json handles anything with a well-defined shape:
- All integer and float primitives (
i8–i64,u8–u64,f64,bool). -
[]const u8and other slices (as JSON strings or arrays). - Optionals (as
nullor the value). - Structs, arrays, enums.
- Nested combinations of the above.
For types with custom wire formats, define pub fn jsonStringify(...) and/or pub fn jsonParse(...) on the type. std.json will use them automatically - stringifyAlloc and parseFromBody pass through.
const json = Mesh.json;
const User = struct {
id: u32,
name: []const u8,
};
fn createUser(r: Mesh.zap.Request) void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const user = json.parseFromBody(User, arena.allocator(), r) orelse return;
// `user` is valid here. On any parse failure, parseFromBody already
// sent the error response and returned null - the orelse covers it.
_ = user;
}fn listUsers(r: Mesh.zap.Request) void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const users = [_]User{
.{ .id = 1, .name = "alice" },
.{ .id = 2, .name = "bob" },
};
json.send(arena.allocator(), r, users) catch {};
}The catch {} is idiomatic here. Serializing a fixed struct essentially never fails; if it does, the response is already on the wire by the time we'd know.
Useful in tests, config loaders, or anywhere a request isn't involved:
test "parses a user" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const user = try json.parseFromSlice(
User,
arena.allocator(),
"{\"id\": 7, \"name\": \"carol\"}",
);
try std.testing.expectEqual(@as(u32, 7), user.id);
try std.testing.expectEqualStrings("carol", user.name);
}stringifyAlloc passes its options straight through to std.json.Stringify. For human-readable output:
const text = try json.stringifyAlloc(
allocator,
user,
.{ .whitespace = .indent_2 },
);
defer allocator.free(text);When you need a specific status code for a specific parse failure:
const body = r.body orelse {
response.sendBadRequest(r, "empty body");
return;
};
const user = json.parseFromSlice(User, arena.allocator(), body) catch |err| {
switch (err) {
error.SyntaxError => response.sendBadRequest(r, "malformed JSON"),
else => response.sendInternalServerError(r, "unexpected"),
}
return;
};This is what parseFromBody does under the hood, minus the status-code nuances.
-
Arena is the right allocator. Parsing builds a tree of heap-allocated nodes with cross-references. Freeing that tree manually is a walk over every node. An arena reduces it to one
deinit(). -
The parse helpers do not copy strings. When you parse
[]const u8fields, they point into the original JSON buffer. That buffer lives as long as the request, so the parsed struct is only valid within the handler's scope. If you need to keep it around, copy the strings into a longer-lived allocator before returning. -
std.json.Stringify.Optionsis the authoritative options type.stringifyAllocdoesn't invent its own. Whateverstd.jsonsupports - indent, escape-unicode, emit-null, etc. - passes through. -
The
500for malformed JSON is a known wrinkle. JSON parse failures are almost always client errors. If your API is public, treat them as400by usingparseFromSliceand mapping the error explicitly.