httpx Client/Server Scaffolding from OpenAPI

Web & API Engineering advanced Building with AI ~20 min read

Overview

OpenAPI specifications describe REST APIs in a machine-readable format, and code generators can turn those descriptions into fully typed client libraries and server stubs. An OpenAPI codegen project takes this a further step by generating Zig code that produces ApiResponse wrappers, strongly typed client methods, and ServerRouter wrappers built on top of httpx—a Zig HTTP library that provides the underlying transport layer. The generated code inherits consistent auth header injection, JSON marshalling, and route registration from the httpx foundation, so every endpoint automatically gets the same cross-cutting behavior without manual wiring.

This approach solves the drift problem that plagues hand-written API clients. When the OpenAPI spec changes—a new field is added to a response, a parameter becomes required, an endpoint is deprecated—regenerating the code immediately surfaces type errors at compile time rather than failing silently at runtime. The Zig type system is strict enough that a missing field in a response struct will refuse to compile, making the generated code a compile-time contract between client and server.

The architecture separates generated code from business logic through a wrapper pattern. The codegen produces structs for request/response types, a client struct with methods for each endpoint, and a router struct that maps HTTP paths to handler functions. Developers implement the handler functions in separate files that import the generated types, so regenerating the code never overwrites business logic. The httpx layer handles connection pooling, TLS, timeouts, and keep-alive semantics, while the generated layer handles serialization, validation, and routing.

Core Concepts

ApiResponse Wrappers

Every generated client method returns an ApiResponse(T) that encapsulates the HTTP status code, response headers, and a deserialized body of type T. This wrapper provides a uniform interface for checking success/failure, extracting pagination headers, and accessing the typed response body without manual JSON parsing. Error responses are deserialized into a separate error type, so callers can pattern-match on success vs. error without casting.

Strongly Typed Client Methods

For each endpoint defined in the OpenAPI spec, the code generator emits a method on the Client struct that accepts typed parameters (path params, query params, request body) and returns a typed ApiResponse. The method handles URL construction, query string encoding, JSON serialization of the request body, and deserialization of the response—eliminating an entire class of bugs related to string formatting and manual JSON handling.

ServerRouter Registration

On the server side, the generator produces a ServerRouter that maps HTTP method + path combinations to handler function pointers. During initialization, the router registers each generated route with the underlying httpx server, extracting path parameters and deserializing request bodies before invoking the handler. This ensures that by the time a handler function is called, all input has been validated and deserialized into typed Zig structs.

Auth Header Injection

The httpx client supports middleware-style interceptors that modify outgoing requests before they are sent. The generated client constructor accepts an auth configuration (API key, bearer token, or custom header) and installs an interceptor that injects the appropriate header on every request. This centralizes auth handling so individual endpoint methods never need to manage credentials, and switching auth strategies requires changing a single configuration value rather than modifying every call site.

JSON Marshalling via Comptime Reflection

Zig’s compile-time reflection (@typeInfo) allows the serialization layer to inspect struct fields at comptime and generate optimal serialization/deserialization code with zero runtime overhead. The generated types include pub const json_options declarations that control field naming (camelCase vs. snake_case), optional field handling, and enum serialization strategy. This eliminates the need for runtime reflection or annotation processing that languages like Java or Go require.

How It Works

Generated Client Structure

The code generator reads the OpenAPI spec and produces a Zig file containing request/response types and a client struct. Each endpoint becomes a method that constructs the HTTP request, sends it through httpx, and deserializes the response.

const std = @import("std");
const httpx = @import("httpx");

pub const CreateUserRequest = struct {
    name: []const u8,
    email: []const u8,
    role: enum { admin, member, viewer },

    pub const json_options = .{
        .field_naming = .camelCase,
    };
};

pub const User = struct {
    id: u64,
    name: []const u8,
    email: []const u8,
    role: []const u8,
    created_at: []const u8,

    pub const json_options = .{
        .field_naming = .camelCase,
    };
};

pub fn ApiResponse(comptime T: type) type {
    return struct {
        status: u16,
        headers: httpx.Headers,
        body: union(enum) {
            success: T,
            err: ApiError,
        },

        pub fn isSuccess(self: @This()) bool {
            return self.status >= 200 and self.status < 300;
        }

        pub fn unwrap(self: @This()) !T {
            return switch (self.body) {
                .success => |val| val,
                .err => |e| return error.ApiError,
            };
        }
    };
}

Client Methods with Auth Injection

The generated client holds an httpx connection pool and auth configuration. Each method builds the URL, serializes parameters, and delegates to httpx for the actual HTTP call. Auth headers are injected automatically by the underlying transport.

pub const Client = struct {
    pool: *httpx.ConnectionPool,
    base_url: []const u8,
    auth: AuthConfig,
    allocator: std.mem.Allocator,

    pub const AuthConfig = union(enum) {
        bearer: []const u8,
        api_key: struct { header: []const u8, value: []const u8 },
        none: void,
    };

    pub fn init(allocator: std.mem.Allocator, base_url: []const u8, auth: AuthConfig) !Client {
        return .{
            .pool = try httpx.ConnectionPool.init(allocator, .{
                .max_connections = 32,
                .idle_timeout_ms = 60_000,
            }),
            .base_url = base_url,
            .auth = auth,
            .allocator = allocator,
        };
    }

    pub fn createUser(self: *Client, req: CreateUserRequest) !ApiResponse(User) {
        var request = try httpx.Request.init(self.allocator, .POST, self.base_url ++ "/api/v1/users");
        try request.setJsonBody(req);
        self.injectAuth(&request);

        const response = try self.pool.send(request);
        return self.parseResponse(User, response);
    }

    pub fn getUser(self: *Client, user_id: u64) !ApiResponse(User) {
        const path = try std.fmt.allocPrint(self.allocator, "{s}/api/v1/users/{d}", .{ self.base_url, user_id });
        var request = try httpx.Request.init(self.allocator, .GET, path);
        self.injectAuth(&request);

        const response = try self.pool.send(request);
        return self.parseResponse(User, response);
    }

    fn injectAuth(self: *Client, request: *httpx.Request) void {
        switch (self.auth) {
            .bearer => |token| request.setHeader("Authorization", "Bearer " ++ token),
            .api_key => |ak| request.setHeader(ak.header, ak.value),
            .none => {},
        }
    }
};

ServerRouter with Route Registration

On the server side, the generator produces a router that maps OpenAPI paths to handler functions. Path parameters are extracted and passed as typed arguments, and request bodies are deserialized before the handler is invoked.

pub const ServerRouter = struct {
    router: httpx.Router,

    pub fn init(allocator: std.mem.Allocator) ServerRouter {
        var r = httpx.Router.init(allocator);
        return .{ .router = r };
    }

    pub fn registerHandlers(self: *ServerRouter, handlers: Handlers) void {
        self.router.post("/api/v1/users", struct {
            fn handle(ctx: *httpx.Context) !void {
                const body = try ctx.parseJson(CreateUserRequest);
                const result = try handlers.createUser(body);
                try ctx.json(result, .{});
            }
        }.handle);

        self.router.get("/api/v1/users/:id", struct {
            fn handle(ctx: *httpx.Context) !void {
                const id = try std.fmt.parseInt(u64, ctx.param("id"), 10);
                const result = try handlers.getUser(id);
                try ctx.json(result, .{});
            }
        }.handle);
    }

    pub const Handlers = struct {
        createUser: *const fn (CreateUserRequest) anyerror!User,
        getUser: *const fn (u64) anyerror!User,
    };
};

Using the Generated Code

Application code imports the generated module and provides handler implementations. The generated types ensure compile-time agreement between the OpenAPI spec and the actual implementation.

const api = @import("generated/api.zig");
const httpx = @import("httpx");

fn handleCreateUser(req: api.CreateUserRequest) !api.User {
    const user = try db.insertUser(.{
        .name = req.name,
        .email = req.email,
        .role = @tagName(req.role),
    });
    return .{
        .id = user.id,
        .name = user.name,
        .email = user.email,
        .role = user.role,
        .created_at = user.created_at,
    };
}

fn handleGetUser(id: u64) !api.User {
    return db.findUser(id) orelse return error.NotFound;
}

pub fn main() !void {
    var router = api.ServerRouter.init(allocator);
    router.registerHandlers(.{
        .createUser = handleCreateUser,
        .getUser = handleGetUser,
    });

    var server = try httpx.Server.init(allocator, .{ .port = 8080 });
    try server.serve(router.router);
}

Practical Applications

This scaffolding pattern ensures that every REST endpoint in a service mesh has a compile-time-verified client and server implementation. When the search API adds a new filter parameter, the OpenAPI spec is updated, the code is regenerated, and any client that doesn’t pass the new parameter gets a compile error if the parameter is required—or silently gains support for it if it’s optional. This eliminates the integration testing burden of verifying that clients send the correct parameters.

The ApiResponse wrapper standardizes error handling across all services. Instead of each client method having its own error-handling logic, callers use a uniform isSuccess() / unwrap() interface. This makes it straightforward to build generic retry logic, circuit breakers, and observability middleware that work with any generated client without knowing the specific endpoint types.

The auth injection pattern is particularly valuable in microservice architectures where different services may use different authentication mechanisms. A service-to-service call might use a mutual TLS certificate mapped to an API key, while a user-facing call uses a JWT bearer token. The generated client abstracts this behind the AuthConfig union, so the calling code doesn’t change when the auth strategy is rotated or upgraded.

Implementation Patterns

Partial Regeneration with File Hashing

Regenerating all client and server code on every spec change is wasteful when only a few endpoints changed. The code generator can hash each endpoint’s definition and compare it against the hash stored in a manifest file. Only endpoints whose hashes differ are regenerated, preserving file modification timestamps for unchanged files and avoiding unnecessary recompilation by the Zig build system.

Separation of Generated and Hand-Written Code

Generated files live in a generated/ directory that is fully owned by the code generator. Hand-written handler implementations import from generated/ but live in separate directories. This separation means generated/ can be blown away and recreated without risk of losing business logic, and .gitignore can optionally exclude it if the team prefers to regenerate on build rather than commit generated code.

Compile-Time Route Table Verification

The ServerRouter.registerHandlers method accepts a struct of function pointers. If the OpenAPI spec adds a new endpoint, the Handlers struct gains a new field, and any call to registerHandlers that doesn’t provide the new handler fails at compile time. This makes it impossible to deploy a server that is missing a handler for a spec-defined endpoint.

Common Pitfalls

Further Reading