I maintain a Golang version of the SDK for EspoCRM and I’ve wanted to write one in Zig. I did write a mostly functional one upon release of the 0.14.x compiler, but things were changing rapidly in Zig so I paused the project. Now, with the introduction of the std.Io functionality, we’re getting closer to a stable product and I’m motivated to finish it.
This SDK building means I need to work with HTTP API endpoints in Zig and working with JSON. Let me show you how I serialize and deserialize JSON in Zig to work with data from most HTTP APIs.
Deserializing JSON #
Here’s what seems like the straightforward approach to me, take a JSON string and turn it into a struct. We’ll write a function that wraps json.parseFromSlice from the std library.
pub fn Deserialize(allocator: std.mem.Allocator, comptime T: type, json_str: []const u8) !std.json.Parsed(T) {
const parsed = try std.json.parseFromSlice(T, allocator, json_str, .{});
return parsed;
}
This takes in raw JSON and returns a json Parsed struct that owns all it’s own memory (it’s got an arena allocator built-in). Let’s use it with a simple example. There’s a leaky variant of the Parsed struct we won’t get into as this one works fine for my use-case. Because the struct has a built-in arena allocator we have to call deinit to clean up when we’re done.
const my_json_str =
\\{
\\ "userid": 103609,
\\ "verified": true,
\\ "access_privileges": [
\\ "user",
\\ "admin"
\\ ]
\\}
;
const User = struct {
userid: i32,
verified: bool,
access_privileges: []const []const u8,
};
const parsed = try Deserialize(allocator, User, my_json_str);
defer parsed.deinit();
std.debug.print("{d}\n", .{parsed.value.userid}); // prints: 103609
Works great! We can store parsed.value in a variable to make it easy for the reader to know what is the underlying data, but for me I just access values directly. But what happens when the JSON has fields we don’t expect?
When JSON doesn’t match your struct #
APIs evolve, developers add features and responses change. Let’s say the API now returns an email field, but our User struct doesn’t have it.
const new_json =
\\{
\\ "userid": 103609,
\\ "verified": true,
\\ "email": "[email protected]",
\\ "access_privileges": ["user", "admin"]
\\}
;
const parsed = try std.json.parseFromSlice(User, allocator, new_json, .{});
This fails with error.MissingField. The parser, by default, requires exact matches between JSON fields and struct fields. Any extra field is treated as an error. That’s why we need to tell the parser to ignore unknown fields. Let’s add an option to the json.parseFromSlice method.
pub fn Deserialize(allocator: std.mem.Allocator, comptime T: type, json_str: []const u8) !std.json.Parsed(T) {
const parsed = try std.json.parseFromSlice(T, allocator, json_str, .{ .ignore_unknown_fields = true });
return parsed;
}
Sweet, now the parser skips any fields it doesn’t recognize, however, be aware this can result in missing data since any difference between the struct and the JSON string are silently dropped.
Accessing values in std.json.Parsed(T) #
When we parse JSON to a struct, to access the actual parsed data, I use .value of the Parsed type returned. Again, you can store this in a variable like const user = parsed.value; to make intent clear to the reader, but that’s up to you.
std.debug.print("{d}\n", .{parsed.value.userid});
std.debug.print("{any}\n", .{parsed.value.access_privileges});
Serializing JSON #
Now, going from struct to JSON string is bit more straightforward.
pub fn Serialize(allocator: std.mem.Allocator, any: anytype) ![]u8 {
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
var write_stream: std.json.Stringify = .{
.writer = &out.writer,
.options = .{ .whitespace = .indent_2 },
};
try write_stream.write(any);
return out.toOwnedSlice();
}
Putting it together #
Here’s a complete example using both serialization and deserialization.
const std = @import("std");
const User = struct {
userid: i32,
verified: bool,
access_privileges: []const []const u8,
};
pub fn Serialize(allocator: std.mem.Allocator, any: anytype) ![]u8 {
var out: std.Io.Writer.Allocating = .init(allocator);
defer out.deinit();
var write_stream: std.json.Stringify = .{
.writer = &out.writer,
.options = .{ .whitespace = .indent_2 },
};
try write_stream.write(any);
return out.toOwnedSlice();
}
pub fn Deserialize(allocator: std.mem.Allocator, comptime T: type, json_str: []const u8) !std.json.Parsed(T) {
const parsed = try std.json.parseFromSlice(T, allocator, json_str, .{ .ignore_unknown_fields = true });
return parsed;
}
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
const allocator = gpa.allocator();
defer {
const deinit_status = gpa.deinit();
if (deinit_status == .leak) std.testing.expect(false) catch @panic("memory leak detected");
}
const my_json_str =
\\{
\\ "userid": 103609,
\\ "verified": true,
\\ "access_privileges": [
\\ "user",
\\ "admin"
\\ ]
\\}
;
// Deserialize JSON to struct
const deserialized = try Deserialize(allocator, User, my_json_str);
defer deserialized.deinit();
std.debug.print("User ID: {d}\n", .{deserialized.value.userid});
// Serialize struct to JSON
const user: User = .{
.userid = 103609,
.verified = false,
.access_privileges = &.{ "user", "admin" }
};
const serialized = try Serialize(allocator, user);
defer allocator.free(serialized);
std.debug.print("{s}\n", .{serialized});
}