Skip to content

ADR 0008: OpenAPI 3.1 document generated from Go types

Amended 2026-10-22 (ADR 0013): the package moved to clients/typescript and is named @xtandard/image. The text below keeps the original packages/image-url / @xtandard/image-url names as decided at the time.

  • Status: accepted (implementation: WP-82, WP-83; router spike: WP-82b, rejected, see “WP-82b outcome”; clients: WP-84; publishing: WP-81b)
  • Date: 2026-09-26
  • Amended: 2026-09-26, with the owner’s answers to ROADMAP open questions 10 (TypeScript layout, see ADR 0009) and 12 (huma as router, decision 5)

The owner wants what ElysiaJS gives: an API description generated from code, used for client codegen (TypeScript via openapi-typescript) and a docs UI. The constraints:

  • stdlib net/http.ServeMux only, no globals (go-standards §2, CLAUDE.md 4 and 11). Every dependency needs an ADR line and an MIT/BSD/Apache/ISC licence (§10). Banned modules include gopkg.in/yaml.v2/v3, google/uuid, testify, chi/gin/echo/gorilla, and go-playground/validator (§7).
  • The surface is unusual. The image endpoint is a path grammar with many dialects, carried by the catch-all GET /{path...} and dialect/router. /info goes through the same catch-all (<mount>/info/..., ARCHITECTURE §2). Neither can be modelled as ordinary OpenAPI path parameters: a path parameter cannot contain /, and allowReserved applies only to query parameters.
    • Ordinary operations: /healthz, /readyz, and on the admin listener /metrics, pprof, the playground (WP-80), the future /openapi.json and /config.schema.json (ADR 0007 decision 6). The POST upload endpoint is deferred until after M3 (AGENT-WORKFLOW “Decisions already taken”).
  • Typed payloads exist but are unexported today. server.infoJSON (the imgproxy-compatible /info body) and the error envelope {"error": {"kind", "message", "request_id"}} in server/errors.go are both unexported.

Versions from proxy.golang.org, licence, stars and last push from the GitHub API, all checked 2026-09-26.

Option Version / licence / activity Fit
github.com/danielgtaylor/huma/v2 v2.39.1 (2026-07-29), MIT, 4.4k★, pushed 2026-09-24 Router. The humago adapter (adapters/humago/humago.go) accepts any Mux with HandleFunc + ServeHTTP and registers METHOD prefix+path, so *http.ServeMux works as is; NewWithPrefix exists.
Imports. The core package imports only stdlib plus its own casing, negotiation, validation and yaml subpackages (checked file by file). The gin/echo/fiber/chi/gorilla/testify/uuid lines in its go.mod belong to other adapters and tests and are pruned from our build; on Go 1.27 it uses stdlib uuid (schema_uuid_go127.go).
OpenAPI. OpenAPI 3.1.0 by default, OpenAPI.Downgrade() to 3.0.3, JSON Schema 2020-12 registry (NewMapRegistry, SchemaFromType). Tags doc, enum, format, example, pattern. TextUnmarshaler types become strings, and SchemaProvider/SchemaTransformer give per-type control. Extensions maps carry x- fields, and OpenAPI.AddOperation adds operations without handlers.
Docs UI. Stoplight Elements, Scalar or Swagger UI, loaded from unpkg with SRI.
Caveat. It has mutable package-level variables (NewError, DefaultArrayNullable, ErrorFormatter, ValidateStrictCasing, DefaultFormats). We never assign them.
github.com/go-fuego/fuego v0.20.0 (2026-07-21), MIT, 1.8k★ Built on Go 1.22 net/http, but it requires go-playground/validator/v10 (rejected in §7), gorilla/schema and gopkg.in/yaml.v3 (banned), and uses kin-openapi. Its automatic features need fuego’s handler signatures. Rejected.
github.com/jkaninda/okapi v1.0.0 released 2026-09-26 (today), MIT, 99★, one main author Has its own router, not ServeMux. Its go.mod requires gopkg.in/yaml.v3 and google/uuid (both banned), plus kin-openapi. Too new to judge. Rejected.
swaggest/rest + swaggest/openapi-go rest v0.2.76 (2026-09-17), openapi-go v0.2.61 (2026-04-14), MIT, one maintainer rest requires go-chi/chi/v5 and gorilla/mux. openapi-go has an openapi31 reflector but requires gopkg.in/yaml.v2 (banned). Its schema model is draft-07-centric. Rejected.
github.com/getkin/kin-openapi (hand-built spec + openapi3gen) v0.149.0 (2026-08-28), MIT, 3.3k★, still v0 It is a document model plus validator, but OAS 3.1 support is recent (v0.148 “carry format validators over to OAS3.1”), and v0 releases break often. openapi3gen reflection is weaker than huma’s. Deps: gorilla/mux (routers package), oasdiff/yaml. Rejected as builder.
oapi-codegen/oapi-codegen/v2 (spec-first) v2.8.0 (2026-07-17), Apache-2.0, 8.6k★ Excellent std-http server generation, and OAS 3.1 support landed (#2336). But it is spec-first, the opposite of “generated from Go types”: /info, errors and config would be hand-maintained YAML, drifting from Go. Rejected (revisit only if we ever go spec-first).
ogen-go/ogen (spec-first) v1.24.0 (2026-08-07), Apache-2.0, 2.1k★ Spec-first, and it generates its own static radix router instead of using ServeMux. Rejected.
Hand-written OpenAPI structs + own reflector none Zero deps, but it reimplements what huma’s registry already does well (refs, 2020-12, nullability, enums). Kept as the fallback if huma becomes unmaintained.
  1. Use huma v2 as the OpenAPI builder, not as the router, in WP-82. Decision 5 sets how the WP-82b spike can change that for JSON endpoints. Until then, handlers stay plain http.HandlerFuncs on our two ServeMuxes.

    • A new package internal/apidoc builds a *huma.OpenAPI (version 3.1.0) from a huma.NewMapRegistry("#/components/schemas/", huma.DefaultSchemaNamer).
    • It declares every public and admin operation with OpenAPI.AddOperation. Response schemas are reflected from exported Go types: server.InfoResponse, renamed from infoJSON, and server.ErrorResponse for the error envelope, with a Kind enum taken from errs.
    • Nothing in huma is mutated. No huma adapter runs at runtime, so no huma error format or validation layer is in the request path.
    • internal/apidoc may import server and transform types. server must not import apidoc: app wires the handler.
  2. Drift is prevented by tests, since the handlers are not generated.

    1. Golden: internal/apidoc/openapi.json is committed, embedded and regenerated with -update. A diff fails CI.
    2. Route coverage: server exposes its registered patterns (public and admin), and a test asserts that they map one-to-one to spec operations. GET /{path...} maps to the documented dialect operations, see item 4.
    3. Contract: handler tests validate real response bodies (/info fixtures of every M1 format, every errs.Kind error, /healthz, /readyz) against the operation’s schema with santhosh-tekuri/jsonschema/v6, which is test-only and shared with ADR 0007.
    4. Consumability: CI runs openapi-typescript and @redocly/cli lint on the committed file. Until the Bun workspace exists, WP-82 runs both with bunx at pinned versions. Once WP-84 lands, openapi-typescript is a pinned devDependency of packages/image-url, and its generate script writes the committed TypeScript types there (ADR 0009).
  3. Serving:

    • GET /openapi.json on the admin listener is always on and cheap: the embedded bytes with a strong ETag.
    • GET /docs is on the admin listener too, behind XI_ADMIN_DOCS=false (default off). It serves Scalar (MIT, the most active renderer, huma supports it) from a pinned jsdelivr URL with SRI and a strict CSP. Nothing is added to the binary.
    • When XI_ADMIN_DOCS_CDN=false, /docs returns 404 and says to point any OpenAPI viewer at /openapi.json. This covers air-gapped installs.
    • The public listener never serves either route.
  4. URL dialects in OpenAPI. One operation per mounted dialect grammar, not per option.

    • imgproxy is documented as GET /{signature}/{processingOptions}/{source}. processingOptions and source are documented as style: simple strings containing /, and the operation carries:
      • x-xi-dialect: imgproxy;
      • x-xi-path-grammar, the EBNF from URL-DIALECTS §3.1;
      • x-xi-processing-options: {"$ref": "#/components/schemas/ImgproxyProcessingOptions"};
      • x-xi-signing, the scheme and its link.
    • Generated HTTP clients are not expected to call it. Its purpose is the type contract for URL builders.
    • /info gets the same treatment with ImgproxyInfoOptions and the real InfoResponse body.
    • ImgproxyProcessingOptions is a JSON Schema object with one property per canonical option name (URL-DIALECTS §3.2). Each value is a positional-argument tuple (prefixItems with typed, ranged and enumerated items), with x-xi-aliases, x-xi-milestone and x-xi-pro annotations.
    • The schema is generated from a declarative option catalogue in internal/dialect/imgproxy, and a test checks the catalogue against the parser’s apply switch in both directions and against URL-DIALECTS §3.2. transform.Request is not exposed. It is the normalised internal model: it holds Dialect and Ignored, carries unit-converted values, and changing it must not become an API break.
  5. huma as router: decided by a spike, WP-82b (open question 12; the owner is indifferent and wants the better option). Typed handlers would make the spec equal the handler (the Elysia model) and replace the route-coverage and contract tests for those operations with compile-time types. So they are worth adopting if they cost nothing on errors or on the server model. Reading huma v2.39.1 shows where that cost could come from:

    • Handler errors are ours. A handler error that implements huma.StatusError is written as it is, with its own status (huma.go, handler path: errors.As(err, &se), then writeResponseWithPanic). Our errs-backed ErrorResponse can implement it.
    • huma’s own errors use a package global. Request validation (“validation failed”, 422 by default), a 406 when a response cannot be marshalled, and the 500 for a non-StatusError error are all built by WriteErr, which calls the package variable NewErrorWithContext, which defaults to NewError (error.go). The documented way to customise them is to assign that variable, and we never assign huma globals.
    • Per-API alternatives exist, but are unproven. Config.Transformers (per API, func(ctx, status, v any) (any, error)) can rewrite a *huma.ErrorModel body into ErrorResponse. A Resolver that returns our StatusError sets the status, because the last StatusError wins. Per-operation SkipValidateParams/SkipValidateBody let a handler validate for itself. Separately, WriteErr prints to os.Stderr, not slog, if writing an error fails.
    • The server model is safe. humago registers METHOD prefix+path on our *http.ServeMux and reads r.PathValue. http.Server, its timeouts, the recover middleware and MaxBytesReader all remain ours.

    WP-82b acceptance criteria (M2, small, after WP-82). It passes only if all of these hold on a branch that moves /healthz, /readyz and one admin JSON route to humago.NewWithPrefix(mux, …):

    1. Every error body on those routes, including huma-generated ones (malformed input, failed validation, an unacceptable Accept, a handler panic, an unexpected error), is our ErrorResponse with the status that errs maps its kind to. For example, invalid input is 400, not huma’s 422. The WP-82 contract tests pass unchanged.
    2. No huma package-level variable is assigned. A forbidigo rule rejects huma\.(NewError|NewErrorWithContext|ErrorFormatter|DefaultArrayNullable|ValidateStrictCasing|DefaultFormats)\s*= across the repo, and the rule is part of the change.
    3. No regression against go-standards §2: routes stay on our stdlib ServeMux, http.Server keeps every timeout, and shutdown, recover and MaxBytesReader are unchanged. go list -deps ./... shows no new third-party module in the build. huma writes nothing to stderr in any tested path.
    4. The generated OpenAPI for the moved routes equals the WP-82 golden, or differs only by an explained, reviewed diff.
    5. /healthz allocations and latency do not regress (for b.Loop() benchmark, benchstat -count=10, go-standards §8).

    If it passes, an amendment moves the JSON endpoints to huma handlers: health, readiness, admin JSON routes, and the post-M3 upload and admin API. Depguard then lets huma into server and obs as well. If it fails, huma stays builder-only, and the spike’s findings are recorded here. Either way, the image endpoints and /info stay hand-written. Both are reached through the dialect catch-all (GET /{path...}, <mount>/info/...), and their path grammar cannot be expressed as OpenAPI path parameters (Context). Non-JSON admin routes (/metrics, pprof, /openapi.json, /config.schema.json, /docs) stay plain handlers too.

  • There is one new runtime dependency, github.com/danielgtaylor/huma/v2 (MIT), confined to internal/apidoc by depguard. It pulls no third-party packages into the build. WP-82 adds it to the ADR 0006 register and go-standards §10.
    • Maintenance risk is moderate: one lead maintainer, but 4.4k★, many contributors and monthly releases.
    • The escape hatch is cheap. apidoc owns only declarations, so a swap to hand-written structs touches one package.
  • The spec is only as true as the tests in item 2. Handlers are free-form, so the contract tests are mandatory, not optional.
  • TS clients get real types for /info, errors, health and readiness, plus ImgproxyProcessingOptions for URL building. The first package is @xtandard/image-url in packages/image-url (WP-84, ADR 0009). Signing a URL still needs the key, so the builder signs server-side or in trusted code, never in the browser.
  • 3.0-only tools can consume OpenAPI.Downgrade() output. If demand shows up, WP-82 can publish openapi-3.0.json too.
  • The config JSON Schema (ADR 0007) stays a separate artefact. OpenAPI can $ref its published URL under components.schemas.Config for documentation, but the two are not generated by the same tool: config has yaml tags, and huma reads json.

Implementation notes (WP-82, WP-83; 2026-09-26)

Section titled “Implementation notes (WP-82, WP-83; 2026-09-26)”
  • Pinned: github.com/danielgtaylor/huma/v2 v2.39.1 (MIT, released 2026-07-29), builder only; github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 (Apache-2.0), test only. Both are in the ADR 0006 register and go-standards §10, and depguard confines them (huma-only-in-apidoc, jsonschema-only-in-tests). TestNoHumaGlobals fails on any assignment to a huma package variable anywhere in the module.
  • Layout: internal/apidoc (Build, Marshal, Spec) and the committed, embedded internal/apidoc/openapi.json (the file ADR 0009 and WP-84 read). It stays under internal/ because go:embed cannot reach outside the package directory and CLAUDE.md rule 2 keeps Go code there; there is no root-level copy to drift. TestGolden (go test ./internal/apidoc -run TestGolden -update) rewrites it.
  • Types: server.InfoResponse (was infoJSON), server.ErrorResponse + server.ErrorBody (the errorJSON body, built by server.NewErrorResponse), errs.Kinds() for the kind enum. doc/minimum/maximum struct tags feed the schema descriptions.
  • Routes: server.Patterns() and admin.Patterns(opts) list what each mux registers (the admin mux is built from one route table). TestRouteCoverage maps both ways: every registered pattern is hit by a documented operation’s sample request, and every operation resolves on its x-xi-listener. GET /config.schema.json is documented ahead of WP-81 registering it; the test tolerates only that path until then. pprof is documented too (/debug/pprof/, /debug/pprof/{profile}, POST /debug/pprof/symbol) because the coverage test covers every admin pattern.
  • Operations (14): /healthz, /readyz (both listeners), admin /metrics, /openapi.json, /config.schema.json, /docs, the three pprof operations, imgproxy /info/{signature}/{options}/{source} and /{signature}/{processingOptions}/{source}, the query dialect /q (real query parameters) and /q/{endpoint} (imaginary), and ImageKit /imagekit/{path}. The dialects without a server mount yet are listed in the root x-xi-dialects extension with their default prefix and URL-DIALECTS link. Error responses are shared components (Error400 … Error504) generated from the errs table plus the ingress 414 and the 501 of not-yet-built request kinds; the ErrorResponse description carries the ARCHITECTURE §12 status table.
  • Option catalogue (WP-83): internal/dialect/imgproxy/catalog.go (ProcessingOptions, InfoOptions) and schema.go (ProcessingOptionsSchema, InfoOptionsSchema, Option.Decode). Enumerations are read from the parser’s own tables. The schemas are plain JSON Schema maps, so imgproxy does not import huma; apidoc inserts them as ImgproxyProcessingOptions / ImgproxyInfoOptions. Besides x-xi-aliases, x-xi-milestone and x-xi-pro, each option carries x-xi-scope, x-xi-syntax, x-xi-default and, for security options, x-xi-security. catalog_test.go checks the catalogue against the apply/applyLater/applyInfo switches (parsed with go/ast) both ways, parses every name and alias with its example in strict mode, compares every §3.2/§3.3 row (names, aliases, Pro, Args, Default, Milestone cells) both ways, and validates every successful golden-table URL, decoded per option occurrence, against the schema. The table gained the sp alias of skip_processing and gifo of gif_options, which the parser already accepted.
  • Serving: admin.Options.OpenAPI (from apidoc.Spec, wired in app) serves /openapi.json as application/json with a strong ETag and 304; admin.Options.Docs (XI_ADMIN_DOCS) serves Scalar 1.72.1 from jsDelivr with an SRI sha384 hash and a CSP that allows only that script URL, the same-origin spec fetch and inline styles (withDefaultFonts:false keeps fonts.scalar.com out). XI_ADMIN_DOCS_CDN was not added: with the single flag off, /docs already answers 404 with a pointer to /openapi.json, which covers air-gapped installs.
  • CI: the openapi job runs pinned bunx [email protected] and bunx @redocly/[email protected] lint on the committed file. Redocly’s no-path-trailing-slash is skipped (pprof’s index pattern is /debug/pprof/); the root security: [{}] states that no HTTP authentication applies (signatures live in the URL, see x-xi-signing).

WP-82b outcome (2026-09-26): rejected, huma stays builder-only

Section titled “WP-82b outcome (2026-09-26): rejected, huma stays builder-only”

The spike (branch m3/wp-81b-82b-pages-huma, commit “apidoc/humaspike: WP-82b spike”, reverted at the end of the branch) registered /healthz and /readyz with huma.Register through humago.New on a plain *http.ServeMux, plus a test-only JSON route with a validated query parameter (n, minimum:1, maximum:9). Decision 5’s criteria, one by one:

  1. Error envelope and status: met, but only through huma internals. A Config.Transformers entry rewrites *huma.ErrorModel (and a StatusError adapter around *errs.Error) into server.ErrorResponse and sets Content-Type: application/json. A transformer cannot change the status: huma decides it first and calls ctx.SetStatus(status) after the transformers (transformAndWrite in huma.go). The spike therefore also installs a per-API middleware that wraps huma.Context, lets the transformer store the errs status and substitutes it in SetStatus. With both hooks, malformed input (n=abc), out-of-range input (n=0, n=10: huma’s 422) and a handler errs error come out as our envelope with 400/400/403, and a plain error is a 500 envelope without its internal text. It works only because of the call order inside transformAndWrite, which is not part of huma’s API; the tests would catch a change, but every huma upgrade would be a risk.

  2. No huma global assigned: met. Only huma.DefaultJSONFormat is read. TestNoHumaGlobals stays green.

  3. go-standards §2 and stderr: partly failed. The ServeMux, http.Server timeouts, shutdown, recover and MaxBytesReader are untouched, and no new module enters the build (humago and queryparam are packages of the huma module we already require). But huma writes to stderr outside our control: WriteErr does fmt.Fprintf(os.Stderr, "could not write error: …") when writing an error body fails, and writeResponseWithPanic panics when marshalling or writing a structured response or a handler’s error body fails while the request context is not yet canceled, which our recover middleware would then log as a handler panic and count. No tested path triggers either, but a broken connection in production can.

  4. Generated OpenAPI: failed without more wrapping. huma.Register documents every operation’s errors with the schema of whatever the package variable huma.NewError returns (defineErrors), i.e. huma’s ErrorModel as application/problem+json, not our ErrorResponse. The spike had to mark the operations Hidden and keep internal/apidoc’s builder as the published document, which removes the benefit that motivated the spike (spec equals handler). Fixing it needs a custom OperationDocumenter that rewrites each operation’s responses.

  5. Allocations and latency: failed. for b.Loop() benchmark of GET /healthz through the mux with an httptest.ResponseRecorder (the recorder’s own allocations are in both columns), benchstat -col /impl, -count=10 -cpu=1 -benchtime=200000x:

    Where hand-written huma delta
    base image builder (linux/arm64, Docker on an M1 Pro) 876.4 ns ± 5%, 1.055 KiB, 12 allocs 1378.5 ns ± 16%, 1.234 KiB, 17 allocs +57% time, +17% bytes, +5 allocs (+42%), p=0.000
    host (darwin/arm64, M1 Pro, load average ≈ 27) 781.1 ns ± 7%, 12 allocs 1294.0 ns ± 5%, 17 allocs +66% time, +5 allocs, p=0.000

    testing.AllocsPerRun gives the same 12 vs 17 for both /healthz and /readyz.

Blocking reasons: criterion 5 fails outright (+5 allocations and about +0.5 µs per request on the probe endpoints load balancers hit most); criterion 1 holds only through an undocumented ordering inside huma; criterion 3 leaves a stderr write and a panic-on-write path in the request path; and criterion 4 needs another wrapper before the generated document could replace the builder. On top of that, /healthz and /readyz are text/plain, so huma adds no typing there: the handler returns a []byte body with hand-set headers.

Consequences: decision 1 stands. huma remains the OpenAPI builder in internal/apidoc only; every handler, JSON or not, stays a plain http.HandlerFunc on our ServeMuxes; the route-coverage and contract tests of decision 2 stay mandatory; depguard keeps huma confined to internal/apidoc. The spike code was removed in the branch’s last commit. What remains is BenchmarkHealthz in internal/obs/admin as the baseline for any future attempt. Revisit only if huma exposes the error status to a per-API hook (or a per-API error constructor replaces the NewError variable) and stops writing to stderr or panicking on write errors.