OpenAPI specifications describe HTTP APIs in a machine-readable format — typically YAML or JSON — that enumerates every endpoint, request/response schema, authentication method, and data model an API exposes. An OpenAPI-to-Zig code generator consumes these specifications and transforms them into Zig source code that provides compile-time-checked client and server stubs. To bridge the gap between a loosely typed JSON document and the strongly typed Zig AST required for code generation, the project employs a two-phase architecture: first a raw parse phase that ingests the JSON into std.json.Value trees, then a resolve phase that walks those trees into a fully typed internal AST with all $ref links replaced by direct pointers to their target definitions.
This two-phase design is not accidental. OpenAPI documents are inherently graph-structured — a schema in the components/schemas section may reference another schema via $ref, which in turn references a third, and circular references are explicitly permitted by the specification. A single-pass parser that tried to build typed structures while simultaneously resolving references would need to handle forward references, mutual recursion, and partial initialization simultaneously. By deferring resolution to a second pass, the parser can build a complete name-to-node index during phase one, then resolve every reference in phase two with a simple lookup — turning a graph-resolution problem into two straightforward linear scans.
Before either phase begins, the raw OpenAPI document (which may be split across multiple YAML files using $ref to external files) is pre-bundled into a single JSON document using an external bundling tool. This means the Zig parser only ever sees a self-contained JSON object where every $ref is a local JSON Pointer (e.g., #/components/schemas/User). This pre-bundling step dramatically simplifies the resolver, which only needs to handle fragment-only references rather than arbitrary URI resolution, file I/O, or network fetches.
Phase 1 (Parse) converts the raw JSON into an untyped std.json.Value tree and builds an index mapping every JSON Pointer path to its corresponding node. Phase 2 (Resolve) walks the std.json.Value tree, converting each node into a strongly typed AST struct (Schema, Operation, Parameter, etc.) and replacing every $ref string with a direct pointer to the resolved AST node. This separation allows forward references and circular schemas to be handled naturally — by the time phase 2 runs, every target node is already indexed and can be looked up in O(1).
A JSON Pointer (RFC 6901) is a string like /components/schemas/User that navigates into a JSON document by successive key lookups. During phase 1, the parser walks the entire JSON tree recursively, recording the JSON Pointer path of every object node in a std.StringHashMap. This index is the backbone of $ref resolution — when phase 2 encounters a {"$ref": "#/components/schemas/User"} node, it strips the leading #, looks up /components/schemas/User in the index, and retrieves the corresponding std.json.Value for typed conversion.
Real-world OpenAPI specs are often split across dozens of files with external $ref links (./models/user.yaml#/User). Rather than implementing full URI resolution in Zig, the code generator requires specs to be pre-bundled into a single JSON file where all references are local JSON Pointers. Tools like swagger-cli bundle or @apidevtools/json-schema-ref-parser handle this preprocessing step. This architectural decision trades a one-time external dependency for dramatically simpler and more reliable Zig code.
The output of phase 2 is a tree of Zig structs — OpenApiDocument, PathItem, Operation, Schema, Parameter, Response — that mirror the OpenAPI 3.x specification’s object model. Each struct uses Zig optionals (?T) for fields that are not required by the spec and slices ([]const T) for array-typed fields. Schema nodes that represent composed types (oneOf, allOf, anyOf) store their sub-schemas as slices of pointers ([]const *Schema), enabling shared references without copying.
OpenAPI permits circular $ref chains (e.g., a TreeNode schema that contains a children field referencing TreeNode). The resolver tracks which nodes are currently being resolved using a visited set. When it encounters a $ref to a node that is already in the visited set, it emits a pointer to a lazily-resolved stub rather than recursing infinitely. The stub is backpatched to point to the final resolved node once the cycle completes, ensuring downstream codegen sees a fully connected type graph.
The code generator’s pipeline starts when a caller passes a pre-bundled JSON byte slice to the top-level parse function. The function orchestrates both phases and returns a fully resolved OpenApiDocument ready for code generation.
The first phase uses Zig’s standard library JSON parser to convert the raw bytes into a std.json.Value tree, then walks the tree to build the JSON Pointer index. Every object node gets an entry in the index keyed by its path from the document root.
// openapi/parser.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Index maps JSON Pointer strings to their corresponding Value nodes.
const RefIndex = std.StringHashMap(*std.json.Value);
/// Phase 1: parse raw JSON bytes and build the $ref index.
pub fn parseAndIndex(
allocator: Allocator,
json_bytes: []const u8,
) !struct { root: std.json.Value, index: RefIndex } {
// Parse the entire JSON document into a Value tree.
var parser = std.json.Parser.init(allocator, false);
defer parser.deinit();
var root = try parser.parse(json_bytes);
// Build the JSON Pointer index by walking every node.
var index = RefIndex.init(allocator);
try buildIndex(allocator, &index, &root, "");
return .{ .root = root, .index = index };
}
/// Recursively walk the JSON tree, recording the path of every object node.
fn buildIndex(
allocator: Allocator,
index: *RefIndex,
node: *std.json.Value,
path: []const u8,
) !void {
switch (node.*) {
.object => |*obj| {
// Record this object node in the index.
try index.put(try allocator.dupe(u8, path), node);
// Recurse into each field.
var it = obj.iterator();
while (it.next()) |entry| {
const child_path = try std.fmt.allocPrint(
allocator,
"{s}/{s}",
.{ path, entry.key_ptr.* },
);
try buildIndex(allocator, index, entry.value_ptr, child_path);
}
},
.array => |*arr| {
for (arr.items, 0..) |*item, i| {
const child_path = try std.fmt.allocPrint(
allocator,
"{s}/{d}",
.{ path, i },
);
try buildIndex(allocator, index, item, child_path);
}
},
else => {},
}
}
The second phase walks the indexed std.json.Value tree and converts each node into the appropriate typed AST struct. When it encounters a $ref key, it resolves the reference using the index built in phase 1 and recursively converts the target node. A visited set prevents infinite recursion on circular references.
// openapi/resolver.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const VisitedSet = std.StringHashMap(void);
/// Resolve a Schema node from a std.json.Value, handling $ref resolution.
pub fn resolveSchema(
allocator: Allocator,
node: *std.json.Value,
index: *const RefIndex,
visited: *VisitedSet,
) !*ast.Schema {
const obj = switch (node.*) {
.object => |*o| o,
else => return error.ExpectedObject,
};
// Check for $ref and resolve it.
if (obj.get("$ref")) |ref_val| {
const ref_str = switch (ref_val) {
.string => |s| s,
else => return error.InvalidRef,
};
// Strip the leading '#' to get the JSON Pointer path.
const pointer = if (std.mem.startsWith(u8, ref_str, "#"))
ref_str[1..]
else
ref_str;
// Circular reference check.
if (visited.contains(pointer)) {
// Return a stub that will be backpatched.
const stub = try allocator.create(ast.Schema);
stub.* = ast.Schema.circularStub(pointer);
return stub;
}
try visited.put(pointer, {});
const target = index.get(pointer) orelse
return error.UnresolvedRef;
const resolved = try resolveSchema(allocator, target, index, visited);
_ = visited.remove(pointer);
return resolved;
}
// Not a $ref — build the Schema from the object fields.
const schema = try allocator.create(ast.Schema);
schema.* = .{
.type_name = getOptionalString(obj, "type"),
.format = getOptionalString(obj, "format"),
.description = getOptionalString(obj, "description"),
.nullable = getOptionalBool(obj, "nullable") orelse false,
.properties = try resolveProperties(allocator, obj, index, visited),
.items = if (obj.get("items")) |items_val|
try resolveSchema(allocator, items_val, index, visited)
else
null,
.one_of = try resolveSchemaArray(allocator, obj, "oneOf", index, visited),
.all_of = try resolveSchemaArray(allocator, obj, "allOf", index, visited),
.any_of = try resolveSchemaArray(allocator, obj, "anyOf", index, visited),
.required = getStringArray(allocator, obj, "required"),
.enum_values = getStringArray(allocator, obj, "enum"),
};
return schema;
}
fn getOptionalString(obj: *std.json.ObjectMap, key: []const u8) ?[]const u8 {
const val = obj.get(key) orelse return null;
return switch (val) {
.string => |s| s,
else => null,
};
}
fn getOptionalBool(obj: *std.json.ObjectMap, key: []const u8) ?bool {
const val = obj.get(key) orelse return null;
return switch (val) {
.bool => |b| b,
else => null,
};
}
Once the resolver has produced a fully typed OpenApiDocument, the codegen module iterates over operations and schemas to emit Zig source files. The typed AST guarantees that every schema reference is already resolved, so codegen never needs to perform lookups or handle missing references — it simply walks the tree and emits corresponding Zig type declarations and function signatures.
// openapi/codegen.zig — simplified excerpt
const ast = @import("ast.zig");
/// Emit a Zig struct declaration for an OpenAPI schema.
pub fn emitStruct(
writer: anytype,
name: []const u8,
schema: *const ast.Schema,
) !void {
try writer.print("pub const {s} = struct {{\n", .{name});
for (schema.properties) |prop| {
const zig_type = mapToZigType(prop.schema);
if (prop.required) {
try writer.print(" {s}: {s},\n", .{ prop.name, zig_type });
} else {
try writer.print(" {s}: ?{s} = null,\n", .{ prop.name, zig_type });
}
}
// Emit jsonParse and jsonStringify for custom serialization.
try writer.print("\n pub fn jsonParse(", .{});
try writer.print("allocator: std.mem.Allocator, source: anytype", .{});
try writer.print(") !{s} {{\n", .{name});
try writer.print(" // Auto-generated field-by-field parsing...\n", .{});
try writer.print(" }}\n", .{});
try writer.print("}};\n\n", .{});
}
The primary consumer of the OpenAPI-to-Zig parser is a client SDK generator. When a new version of an HTTP API is released, the OpenAPI spec is bundled, fed through the two-phase parser, and the codegen module emits updated Zig client code that is compiled directly into applications using the database as a library. Because the generated code is checked into source control, any change to the API surface — a new field, a renamed endpoint, a changed response type — is immediately visible as a compile-time error in downstream code that depends on the old signatures, providing a far stronger correctness guarantee than runtime schema validation.
A second application is server-side request validation. The resolved AST is used at server startup to build a validation table that maps each route pattern to its expected request body schema. Incoming requests are validated against these schemas before handler code executes, catching malformed payloads early and returning structured error responses that reference the specific schema constraint that was violated. This eliminates a large class of handler bugs where missing or mis-typed fields cause panics or silent data corruption deep in business logic.
A third application is documentation generation. The typed AST includes descriptions, examples, and deprecation annotations extracted from the OpenAPI spec, which are used to generate both static HTML reference documentation and interactive API explorers. Because the AST is the single source of truth for all three consumers — codegen, validation, and docs — they can never drift out of sync with each other or with the underlying API.
The entire AST is allocated from a single Zig ArenaAllocator that is freed in one shot after code generation completes. This avoids per-node deallocation bookkeeping and eliminates use-after-free bugs that would otherwise be easy to introduce when schema nodes share pointers due to $ref resolution. The arena is created at the start of the parse call and its lifetime is tied to the returned OpenApiDocument via an opaque handle.
Rather than resolving $ref nodes inline during the tree walk (which complicates the recursive descent with visited-set management at every call site), an alternative pattern used in some codegen passes is to collect all $ref nodes into a deferred queue during traversal, then resolve them in a batch pass after the initial walk completes. This is simpler to implement correctly but requires a backpatching step to wire the resolved pointers into the parent nodes.
OpenAPI’s allOf composition allows a schema to inherit properties from multiple parent schemas. The resolver flattens allOf by merging all parent properties into a single schema node, with later schemas taking precedence for conflicting field names. This produces a simpler AST for codegen to consume — a flat struct with all inherited fields — at the cost of losing the original composition structure. The original allOf references are preserved in an annotation field for documentation purposes.
When a oneOf schema includes a discriminator field, the resolver annotates the resulting union type with the discriminator property name and value-to-variant mapping. This allows codegen to emit Zig tagged unions with a custom jsonParse that reads the discriminator field first, then dispatches to the correct variant parser — avoiding the need for speculative parsing of each union branch.
OpenAPI $ref values for local references include a leading # (e.g., #/components/schemas/User), but the JSON Pointer index is keyed by paths without the fragment prefix (e.g., /components/schemas/User). Forgetting to strip the # before lookup causes every reference to fail resolution. This is one of the most common bugs when first implementing a $ref resolver.
Without a visited set, circular $ref chains cause the resolver to recurse infinitely. The symptom is a stack overflow during parsing, which in Zig manifests as a segfault rather than a catchable error. The fix is straightforward — maintain a hash set of currently-resolving JSON Pointer paths and emit a stub when a cycle is detected — but it must be applied consistently in every resolve function, not just resolveSchema.
JSON Pointer (RFC 6901) requires that / in key names be escaped as ~1 and ~ as ~0. If a schema name contains a slash (rare but permitted), the index builder must escape it when constructing the pointer path, or the resulting key will be misinterpreted as a deeper nesting level. The resolver must correspondingly unescape pointer segments before using them as object key lookups.
In OpenAPI 3.0, nullable: true means the field’s value can be null, but the field itself is still required in the JSON payload. This is distinct from optional (the field may be absent entirely). Conflating the two leads to generated Zig types where a ?T optional is used for both cases, causing the serializer to omit null-valued required fields instead of emitting "field": null. The correct mapping uses ?T for optional and a custom Nullable(T) wrapper for nullable-but-required fields.
When two allOf sub-schemas define the same property with different types, the merger must detect the conflict and report an error rather than silently picking one. This is especially common when an allOf combines a shared base schema with a discriminator-specific extension that accidentally shadows a base field with an incompatible type. Silent shadowing produces codegen that compiles but generates wrong serialization code.