Skip to content
Neon32eeee edited this page Oct 17, 2026 · 1 revision

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:

  1. A single call to go from "request body" to "parsed struct", with the right error response sent on failure.
  2. A single call to go from "any value" to "response body".
  3. 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.

Function breakdown

  • parseFromBody
    Purpose: parse the request body as JSON into type T, 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" null
    Body is not valid JSON 500 Internal Server Error, "Invalid parse JSON" null
    Success - parsed T

    Note the asymmetry: a missing body is treated as a client error (400), but malformed JSON is treated as a server error (500). If you want 400 for malformed JSON too, use parseFromSlice and handle the error yourself.

  • parseFromSlice
    Purpose: parse a JSON string into T without 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 a 200 OK response, 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 !void because serialization can fail. The send itself swallows its errors, as always.

Supported types

std.json handles anything with a well-defined shape:

  • All integer and float primitives (i8–i64, u8–u64, f64, bool).
  • []const u8 and other slices (as JSON strings or arrays).
  • Optionals (as null or 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.

Examples

1. Parsing a request body

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;
}

2. Sending a JSON response

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.

3. Parsing without a request

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);
}

4. Pretty-printed output

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);

5. Manual error handling

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.

Notes

  • 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 u8 fields, 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.Options is the authoritative options type. stringifyAlloc doesn't invent its own. Whatever std.json supports - indent, escape-unicode, emit-null, etc. - passes through.
  • The 500 for malformed JSON is a known wrinkle. JSON parse failures are almost always client errors. If your API is public, treat them as 400 by using parseFromSlice and mapping the error explicitly.

Clone this wiki locally