Zig client library for the NetBox REST API, generated from the OpenAPI schema.
  • Zig 52.5%
  • Nix 47.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 6e520ce91c
All checks were successful
CI / lint (push) Successful in 2m52s
CI / test (push) Successful in 6m16s
CI / publish (push) Successful in 7m59s
Fetch the Zig dependencies through Nix in CI
zon2nix writes build.zig.zon's dependencies out as build.zig.zon.nix,
exposed as the zig-deps package. The test and publish jobs copy that
into zig-pkg/ before building, so Zig finds openapi2zig already
unpacked and the download goes through the Nix cache with everything
else.

That rather than --system, because openapi2zig vendors zig-yaml as a
.path dependency, which Zig 0.17 refuses to resolve from inside a
--system directory without a --fork of the package as well.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MCSwPFzpk3TM5tQvL3W2Vc
2026-10-10 12:35:02 -05:00
.forgejo/workflows Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00
api first 2026-08-27 10:12:11 -05:00
LICENSES first 2026-08-27 10:12:11 -05:00
src Drop the Io argument from the generateCode call 2026-09-12 02:31:50 -05:00
.gitignore first 2026-08-27 10:12:11 -05:00
build.zig add CI workflow publishing docs behind lint and tests 2026-08-30 13:33:07 -05:00
build.zig.zon Repin openapi2zig so null enum tags are written bare 2026-10-10 11:24:21 -05:00
build.zig.zon.nix Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00
flake.lock Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00
flake.nix Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00
README.md Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00
REUSE.toml Fetch the Zig dependencies through Nix in CI 2026-10-10 12:35:02 -05:00

zig-netbox

Zig bindings for the NetBox REST API, generated from the OpenAPI specification that NetBox publishes.

Nothing in this repository is written by hand. api/api.json is NetBox's own schema, and the Zig is produced from it at build time, so the bindings track whatever version of the schema is checked in rather than drifting from it.

API documentation: https://jeff.jcollie.page/zig-netbox/ — generated from the doc comments and from the generated source itself, and published from main by CI.

What is generated

The checked-in schema is NetBox REST API 4.6.9 (4.6): 339 paths and 1146 component schemas, which come out as roughly 2000 public types and 3600 functions.

For each operation the generator emits three functions. Taking dcim_devices_list as the example:

Function Returns Use it when
dcim_devices_list Owned(PaginatedDeviceWithConfigContextList) You want the parsed body and are happy for a non-2xx status to be error.ResponseError.
dcim_devices_listResult ApiResult(PaginatedDeviceWithConfigContextList) You need to tell an API error from a parse error, and to read the error body.
dcim_devices_listRaw RawResponse You want the status and bytes and will do your own parsing.

Query and path parameters arrive as a single options struct — dcim_devices_listOptions — rather than as a long positional argument list.

Owned(T) holds the response body, the std.json.Parsed(T) that borrows from it, and the allocator; call deinit() on it and the pair goes away together. ApiResult(T) is a tagged union of ok, api_error and parse_error, and also has a deinit() that frees whichever arm is live.

Using it

Add the dependency:

zig fetch --save git+https://git.jcollie.dev/jeff/zig-netbox.git

and wire the module up in build.zig:

const netbox_dep = b.dependency("netbox", .{
    .target = target,
    .optimize = optimize,
});
exe_mod.addImport("netbox", netbox_dep.module("netbox"));

Authentication

The key passed to Client.init becomes the Authorization header verbatim, so the scheme prefix is part of it. NetBox's schema documents the two it accepts: Token <token> for a v1 token and Bearer <key>.<token> for a v2 one. A bare token with no prefix is not one of them.

const netbox = @import("netbox");

// The prefix is part of the key, not something the client adds.
var client: netbox.Client = .init(gpa, io, "Token 0123456789abcdef");
defer client.deinit();

client.withBaseUrl("https://netbox.example.com");

var devices = try netbox.dcim_devices_list(&client, .{
    .limit = 50,
    .site_id = &.{ 1, 2 },
});
defer devices.deinit();

for (devices.value().results) |device| {
    std.debug.print("{s}\n", .{device.name orelse "(unnamed)"});
}

An empty key suppresses the header entirely, which is how you reach an endpoint that takes no authentication.

Client.init takes an allocator, an std.Io and the key; the base URL is set separately and defaults to the empty string, so a client that is never given one will make requests against a relative path and fail in a confusing way.

The Client struct also carries organization and project fields that emit OpenAI-Organization and OpenAI-Project headers. NetBox has no use for either — they are the generator's own furniture, and leaving them null is correct.

Filters that take several values

Most NetBox filters accept more than one value, and the generated field is a slice — site_id: ?[]const i64, name: ?[]const []const u8. Each element is sent as a repeat of the key, which is what NetBox reads as "any of these":

.{ .site_id = &.{ 1, 2 } }   //  ?site_id=1&site_id=2

Filters that genuinely take one value stay scalar: limit is ?i64, brief is ?bool.

Fields with a fixed set of values

A choice list becomes a Zig enum — 153 of them, covering 214 fields and filters — so a wrong value is a compile error rather than a 400:

var devices = try netbox.dcim_devices_list(&client, .{ .face = .front });

The tag is the wire value, escaped where it is not a bare identifier, so nothing is lost in translation and nothing has to be looked up:

pub const FaceEnum = enum { front, null, rear };

The null tag is not the absence of a value — it is NetBox's literal string "null", which its filters accept to mean "has none", written .null. An absent value is the ? on the field.

On the response side NetBox wraps a choice in a {value, label} object, so the enum is a field deeper down:

pub const PrefixStatus = struct {
    value: ?StatusEnum = null,
    label: ?LabelEnum20 = null,
};

if (prefix.status) |status| {
    if (status.value == .active) { ... }
}

Two kinds of choice list stay []const u8, because neither can use its values as tag names: one containing the empty string, since @"" is not a legal Zig identifier, and one whose values are not strings. NetBox allows blank on many of the writable and {value, label} variants, so a field can be an enum in one place and a string in another — dcim_devices_list's face filter is a ?FaceEnum while DeviceWithConfigContextFace.value is a ?[]const u8.

Some array filters carry an x-spec-enum-id but no values at all, and there is nothing to build a type from in that case; those stay ?[]const []const u8.

How the generation works

build.zig builds src/generate.zig into a small executable, feeds api/api.json to it on standard input, and captures its standard output as api.zig. That captured file — never written into the source tree — is the root source of the netbox module, and is also installed to zig-out/api.zig so it can be read when something needs explaining.

The generator itself is a thin driver around openapi2zig; src/generate.zig parses the schema into openapi2zig's unified document and asks it for code, with parameters_as_struct on, which is what produces the options structs described above.

The dependency points at a fork rather than at upstream, for three changes this schema needs. Upstream maps every type: array query parameter to []const u8, which covers 77% of NetBox's parameters and leaves no way to send more than one value; it flattens the one-member allOf that OpenAPI 3.0 requires for a nullable $ref, which copies the target's fields under a name built from the enclosing type and the property — thirteen of those collided with real schemas and the result did not compile; and it ignores enum entirely.

Enum generation is off by default there and turned on by src/generate.zig. The grouping uses x-spec-enum-id, the marker drf-spectacular writes to say which schemas share a choice set — worth knowing about because the values alone do not settle it. The same set appears with a null variant added or not depending on whether it is a nullable field or a filter, so 44 of NetBox's 100 ids turn up with two or three different value lists; the generator takes their union.

Upgrading to a new NetBox release is therefore a matter of replacing api/api.json with the schema from that release. A running NetBox will hand you its own:

curl -o api/api.json https://netbox.example.com/api/schema/?format=json

Regenerating takes a while and a fair amount of memory — the schema is 14 MB, and the Zig that comes out of it is about 6 MB.

Building

nix develop            # Zig 0.17, zon2nix, reuse, git-pages-cli
zig build              # generates api.zig and installs it
zig build test         # runs the module's tests
zig build docs         # writes the API documentation to zig-out/docs

The documentation has to be served over HTTP, not opened as a file: the viewer is a WebAssembly program that fetches sources.tar and main.wasm at runtime, and a browser refuses both from a file:// page. Point any static server at zig-out/docs, or read the published copy linked above.

build.zig.zon.nix is the same dependency set as build.zig.zon, written out for Nix by zon2nix and exposed as the flake's zig-deps package. CI copies it into zig-pkg/ before building, so Zig finds every package already there and the downloads come from the Nix cache instead. It is generated, and regenerated whenever a dependency changes:

nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

The dev shell sets SSL_CERT_FILE explicitly, because the CI runners have no system CA bundle and Zig's TLS cannot fetch dependencies without one.

Where this lives

The canonical repository is on Forgejo, with mirrors on GitHub and Tangled:

git clone https://git.jcollie.dev/jeff/zig-netbox.git
git clone https://github.com/jcollie/zig-netbox.git
git clone https://tangled.org/jcollie.dev/zig-netbox

It is also on Radicle, where its Repository ID is

rad:z3mWmgeb7htjXzgjbr5F9PyoXx9p

and that ID is the only way to find it, since Radicle has no central index to search:

rad clone rad:z3mWmgeb7htjXzgjbr5F9PyoXx9p

Licensing

This project follows the REUSE standard, and nix develop -c reuse lint is part of CI.

The code here is MIT. api/api.json is NetBox's schema and is Apache-2.0, copyright NetBox Labs; the Zig generated from it inherits that. Full texts are in LICENSES/.