No description
  • Zig 88.9%
  • Nix 11.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-10 13:15:36 -05:00
api first 2026-09-11 06:46:39 -05:00
LICENSES Document licensing to the REUSE standard 2026-09-11 07:24:10 -05:00
src Upgrade to Zig 0.17 2026-10-10 13:13:24 -05:00
.gitignore Document licensing to the REUSE standard 2026-09-11 07:24:10 -05:00
build.zig Upgrade to Zig 0.17 2026-10-10 13:13:24 -05:00
build.zig.zon Upgrade to Zig 0.17 2026-10-10 13:13:24 -05:00
flake.lock Upgrade to Zig 0.17 2026-10-10 13:13:24 -05:00
flake.nix Upgrade to Zig 0.17 2026-10-10 13:13:24 -05:00
README.md Mention the Tangled mirror 2026-10-10 13:15:36 -05:00
REUSE.toml Document licensing to the REUSE standard 2026-09-11 07:24:10 -05:00

zig-informacast

Zig bindings for the Singlewire InformaCast API, generated from the OpenAPI specification Singlewire publishes.

Nothing here is written by hand. api/api.json is Singlewire'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.

What is generated

The checked-in schema is the InformaCast Mobile API Explorer, v1 (OpenAPI 3.1): 426 paths and 615 component schemas, which come out as about 1400 structs, 147 enums and 1974 functions.

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

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

Query and path parameters arrive as one options struct — required ones are plain fields, optional ones default to null.

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

Many of Singlewire's type names are not Zig identifiers — they are written supervisors.response, _incident-plans.base and so on — so they appear escaped, as @"supervisors.response". That is a spelling, not a different kind of type.

Using it

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

pub fn main(init: std.process.Init) !void {
    const token = init.environ_map.get("INFORMACAST_TOKEN").?;

    var ic: informacast.Client = .init(init.gpa, init.io, token);
    defer ic.deinit();

    var supervisor = try informacast.getSupervisor(&ic, .{
        .supervisorId = "0a466b30-7670-11e5-ba6e-765ae9d26291",
        .connectionStatus = .CONNECTED,
        .includeLatestState = true,
    });
    defer supervisor.deinit();

    std.debug.print("{s}\n", .{supervisor.value().name orelse "(unnamed)"});
}

Client.init takes an allocator, an std.Io and the token. The base URL defaults to https://api.icmobile.singlewire.com/api, the server the schema declares, and withBaseUrl overrides it.

Authentication

The schema declares bearer_auth as an HTTP bearer scheme, so the client adds the Bearer prefix itself — pass the bare token, not a whole header value. An empty token suppresses the Authorization header entirely.

The schema also declares an OAuth 2.0 authorization-code flow with PKCE (icmobile_auth). None of that is generated: getting a token is the caller's problem, and this client only knows how to present one.

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

Fields with a fixed set of values

Where the schema gives a fixed set of choices, the generated type is a Zig enum, so a wrong value is a compile error rather than a rejected request:

pub const ConnectionStatusEnum = enum { CONNECTED, DISCONNECTED, UNKNOWN };

The tag is the wire value verbatim, escaped where it is not a bare identifier, so nothing is lost in translation. 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.

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 informacast module.

The generator is a thin driver around openapi2zig, asking it for code with parameters_as_struct and generate_enums on.

The dependency points at a fork rather than at upstream, for changes this schema needs. The one it cannot do without: 31 of its 615 components are written as a bare $ref naming a "merged" definition next door, and upstream emits nothing at all for such a component while every use of the name remains — 214 references to types nothing declared. The fork emits an alias:

pub const @"_notifications.response" = @"_notifications.merged-response";

The fork also generates enums, maps array query parameters to slices rather than to a single string, and keeps a one-member allOf wrapping a $ref as a reference instead of flattening it into a copy.

Upgrading to a newer schema is a matter of replacing api/api.json.

Building

nix develop      # Zig 0.17 and reuse
zig build        # generates api.zig and compiles all of it
zig build test   # the same, as a test

Every declaration in the generated module is referenced and compiled, since Zig only analyzes what something uses and a broken corner of the bindings would otherwise go unnoticed until a dependent reached it.

Nothing is installed: the bindings are the informacast module, for another project to import. Add it as a dependency with

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

and import it in that project's build.zig:

const informacast = b.dependency("informacast", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("informacast", informacast.module("informacast"));

Generation takes the better part of a minute: the schema is 3.5 MB and the Zig that comes out of it is 2.6 MB.

Where this lives

git clone https://git.jcollie.dev/jeff/zig-informacast.git

It is mirrored on Tangled at

https://tangled.org/jcollie.dev/zig-informacast

It is also on Radicle, where its Repository ID is

rad:z2sWLU3ghuSwq2UT5jMJcDUE9bPAL

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

rad clone rad:z2sWLU3ghuSwq2UT5jMJcDUE9bPAL

Licensing

This project follows the REUSE standard, and nix develop -c reuse lint passes.

The code here is MIT. api/api.json is Singlewire's schema, redistributed as received; it states no license of its own, so it is marked LicenseRef-Singlewire-API-Spec, whose text in LICENSES/ records that absence rather than granting anything. Read Singlewire's terms before redistributing it.