Zig Build Integration for Generated APIs

Systems Programming intermediate Building with AI ~20 min read

Overview

API client libraries are often generated from OpenAPI specifications using code generation tools. In most ecosystems, this generation step happens outside the build system — a developer runs a CLI tool, commits the generated code, and the build system compiles it like any hand-written source. This workflow has a fundamental consistency problem: the generated code can drift from the spec if someone updates the spec without re-running the generator, and generated files committed to version control create noisy diffs that obscure meaningful changes.

The OpenAPI code generator takes a different approach by integrating API code generation directly into Zig’s build system. A build.zig helper runs the oapi-gen CLI as a build step, wires --generate flags and import mappings as build arguments, and registers the generated root.zig as a module dependency. The result is that any project depending on the OpenAPI-generated client gets fresh, spec-consistent code every time it builds — with no generated files checked into version control.

This pattern leverages Zig’s build system philosophy: builds are programs, not configuration files. The build.zig file is executable Zig code that can invoke external tools, manipulate paths, and declare dependencies with full programmatic control. Understanding how to integrate code generation into this system is essential for any Zig project that consumes external specifications or schemas.

Core Concepts

Build-Time Code Generation

Instead of generating code as a manual pre-build step, the code generator runs automatically during the build. Zig’s build system supports this through addSystemCommand, which executes an arbitrary command and captures its output. The build graph tracks the dependency: if the OpenAPI spec file changes, the generation step re-runs, and all downstream compilation steps rebuild against the fresh generated code.

Module Registration

Zig’s package manager organizes code into modules — named units of source code that other modules can @import. The build helper registers the generated root.zig as a module named (for example) "api", so consuming code writes const api = @import("api"); and gets full type-checked access to the generated client types, request builders, and response structures. The module abstraction means the consuming code doesn’t know or care that its dependency is generated.

Import Mappings

OpenAPI specs often reference shared schemas across multiple files using $ref. The code generator needs to resolve these references into Zig import paths. Import mappings tell the generator how to translate OpenAPI schema references (e.g., #/components/schemas/User) into Zig module paths (e.g., @import("models").User). The build helper passes these mappings as CLI arguments, allowing different projects to customize how shared types are resolved.

Zig Build Steps and Dependencies

Zig’s build system is a directed acyclic graph (DAG) of steps. Each step declares what it produces and what it depends on. The code generation step depends on the spec file and produces the generated root.zig. The compilation step depends on root.zig. Zig’s build runner evaluates only the steps needed for the requested target, so the generator only runs when building something that imports the generated module — not on every build.

Hermetic Builds

By generating code inside the build graph rather than relying on pre-committed files, the build becomes more hermetic: given the same spec file and generator version, every build produces identical output. There’s no risk of a developer forgetting to regenerate after a spec change, and CI builds match local builds exactly. The generated code exists only in the build cache, not in the source tree.

How It Works

The integration centers on a helper function in build.zig that sets up the code generation step, connects it to the build graph, and exposes the generated code as an importable module.

Build Helper Structure

// build.zig — Helper for integrating OpenAPI code generation.
const std = @import("std");

pub const OpenApiOptions = struct {
    /// Path to the OpenAPI spec file (YAML or JSON).
    spec_path: []const u8,
    /// Name of the generated module (used in @import).
    module_name: []const u8 = "api",
    /// Additional --generate flags (e.g., "client", "types", "server").
    generate: []const []const u8 = &.{ "client", "types" },
    /// Import mappings: spec $ref -> Zig module path.
    import_mappings: []const ImportMapping = &.{},
};

pub const ImportMapping = struct {
    schema_ref: []const u8,
    zig_import: []const u8,
};

pub fn addOpenApiModule(
    b: *std.Build,
    options: OpenApiOptions,
) *std.Build.Module {
    // Step 1: Set up the code generation command.
    const gen_cmd = b.addSystemCommand(&.{"oapi-gen"});

    // Add the spec file as an input dependency.
    gen_cmd.addFileArg(b.path(options.spec_path));

    // Add --generate flags.
    for (options.generate) |flag| {
        gen_cmd.addArgs(&.{ "--generate", flag });
    }

    // Add import mappings.
    for (options.import_mappings) |mapping| {
        gen_cmd.addArgs(&.{
            "--import-mapping",
            mapping.schema_ref,
            mapping.zig_import,
        });
    }

    // Capture the output directory.
    const output = gen_cmd.addOutputDirectoryArg("generated");

    // Step 2: Create a module from the generated root.zig.
    const module = b.addModule(options.module_name, .{
        .root_source_file = output.path(b, "root.zig"),
    });

    return module;
}

Consuming the Generated Module

// build.zig — A project that uses the generated API client.
const std = @import("std");
const openapi = @import("oapi-gen-build");  // The helper package.

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Generate the API client module from the spec.
    const api_module = openapi.addOpenApiModule(b, .{
        .spec_path = "specs/myservice-api.yaml",
        .module_name = "myservice_api",
        .generate = &.{ "client", "types" },
        .import_mappings = &.{
            .{ .schema_ref = "#/components/schemas/Collection",
               .zig_import = "models" },
        },
    });

    // Build the main executable with the generated module as a dependency.
    const exe = b.addExecutable(.{
        .name = "my-service",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe.root_module.addImport("myservice_api", api_module);

    b.installArtifact(exe);
}

Using Generated Types in Application Code

// src/main.zig — Application code that imports the generated API client.
const std = @import("std");
const api = @import("myservice_api");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // The generated client provides typed methods for each API endpoint.
    var client = try api.Client.init(allocator, .{
        .base_url = "http://localhost:9080",
    });
    defer client.deinit();

    // Create a collection — fully typed request and response.
    const collection = try client.createCollection(.{
        .name = "embeddings",
        .dimension = 768,
        .metric = .cosine,
    });

    std.debug.print("Created collection: {s} (id: {s})\n", .{
        collection.name, collection.id,
    });

    // List collections — returns a typed slice.
    const collections = try client.listCollections(.{
        .limit = 10,
        .offset = 0,
    });

    for (collections.items) |col| {
        std.debug.print("  - {s}: {d} dimensions\n", .{
            col.name, col.dimension,
        });
    }
}

Practical Applications

This build integration pattern is ideal for providing Zig clients for a service’s REST API. When the API spec is updated (new endpoints, changed request schemas), projects that depend on the generated module automatically get updated client code on their next build. Compilation errors surface immediately if application code uses removed or renamed fields, turning spec drift into a compile-time error rather than a runtime surprise.

This approach is especially powerful in a monorepo where the API spec lives alongside the server implementation. A pull request that changes an endpoint in the server code and updates the spec file will cause CI to rebuild all dependent Zig services. If any service’s code is incompatible with the new spec, the build fails in the PR — before the change is merged. This catches integration issues at the earliest possible point.

The import mapping feature handles the common case where multiple services share common types (like Collection, Vector, Error). Rather than generating duplicate type definitions in each client, the shared types live in a common models module, and the import mappings tell each generated client to reference those shared types instead of generating its own copies.

Implementation Patterns

Caching Generated Output

Zig’s build cache automatically handles this: if the spec file hasn’t changed and the generator binary hasn’t changed, the cached generated output is reused. The build system tracks input file hashes and command arguments, so re-running zig build without spec changes skips the generation step entirely.

Generator Version Pinning

Pin the oapi-gen CLI version in build.zig.zon (Zig’s package manifest) so that all developers and CI use the same generator version. Different generator versions may produce different output, which would cause inconsistent builds across machines.

// build.zig.zon
.{
    .name = "my-service",
    .version = "0.1.0",
    .dependencies = .{
        .oapi_gen = .{
            .url = "https://github.com/example/oapi-gen/archive/v0.5.2.tar.gz",
            .hash = "1220a1b2c3d4e5f6...",
        },
    },
}

Spec Validation Before Generation

Add a validation step before the generation step that checks the spec file for errors. A malformed spec should fail the build with a clear error message rather than producing garbage generated code that causes confusing compilation errors downstream.

Multiple Specs, Multiple Modules

A service that consumes multiple APIs (e.g., both an internal service API and an external payment API) can call addOpenApiModule multiple times with different specs and module names. Each generated module is independent and importable by its assigned name.

Common Pitfalls

Generator Not on PATH

The addSystemCommand call expects oapi-gen to be available in the build environment’s PATH. If it’s installed via build.zig.zon as a build dependency, you need to reference its binary through the dependency’s build step rather than relying on a global install. A common pattern is to use the dependency’s artifact() method to get the built CLI binary.

Circular Dependencies

If the generated module depends on hand-written types from the main project, and the main project depends on the generated module, you have a circular dependency. Zig’s build system doesn’t allow this. Break the cycle by extracting shared types into a separate module that both the generated code and the main code import.

Non-Deterministic Generation

If the code generator produces non-deterministic output (e.g., map iteration order varies), every build regenerates the code even when the spec hasn’t changed, defeating the cache. Ensure the generator produces deterministic output by sorting maps and using stable formatting. Test this by running the generator twice on the same spec and diffing the output.

Large Spec Files and Build Times

Complex OpenAPI specs with hundreds of endpoints can take several seconds to process. Since the generation step blocks compilation of dependent modules, this adds directly to build time. Consider splitting large specs into smaller, service-scoped specs so that a change in one service’s API doesn’t regenerate client code for unrelated services.

Further Reading

Further Reading