ADR 0008: OpenAPI 3.1 document generated from Go types
Amended 2026-10-22 (ADR 0013): the package moved to
clients/typescriptand is named@xtandard/image. The text below keeps the originalpackages/image-url/@xtandard/image-urlnames 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)
Context
Section titled “Context”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.ServeMuxonly, 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 includegopkg.in/yaml.v2/v3,google/uuid, testify, chi/gin/echo/gorilla, andgo-playground/validator(§7). - The surface is unusual. The image endpoint is a path grammar with many dialects, carried by the catch-all
GET /{path...}anddialect/router./infogoes through the same catch-all (<mount>/info/..., ARCHITECTURE §2). Neither can be modelled as ordinary OpenAPI path parameters: a path parameter cannot contain/, andallowReservedapplies only to query parameters.- Ordinary operations:
/healthz,/readyz, and on the admin listener/metrics, pprof, the playground (WP-80), the future/openapi.jsonand/config.schema.json(ADR 0007 decision 6). The POST upload endpoint is deferred until after M3 (AGENT-WORKFLOW “Decisions already taken”).
- Ordinary operations:
- Typed payloads exist but are unexported today.
server.infoJSON(the imgproxy-compatible/infobody) and the error envelope{"error": {"kind", "message", "request_id"}}inserver/errors.goare both unexported.
Options considered
Section titled “Options considered”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. |
Decision
Section titled “Decision”-
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 twoServeMuxes.- A new package
internal/apidocbuilds a*huma.OpenAPI(version3.1.0) from ahuma.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 frominfoJSON, andserver.ErrorResponsefor the error envelope, with aKindenum taken fromerrs. - 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/apidocmay importserverandtransformtypes.servermust not importapidoc:appwires the handler.
- A new package
-
Drift is prevented by tests, since the handlers are not generated.
- Golden:
internal/apidoc/openapi.jsonis committed, embedded and regenerated with-update. A diff fails CI. - Route coverage:
serverexposes 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. - Contract: handler tests validate real response bodies (
/infofixtures of every M1 format, everyerrs.Kinderror,/healthz,/readyz) against the operation’s schema withsanthosh-tekuri/jsonschema/v6, which is test-only and shared with ADR 0007. - Consumability: CI runs
openapi-typescriptand@redocly/cli linton the committed file. Until the Bun workspace exists, WP-82 runs both withbunxat pinned versions. Once WP-84 lands,openapi-typescriptis a pinned devDependency ofpackages/image-url, and itsgeneratescript writes the committed TypeScript types there (ADR 0009).
- Golden:
-
Serving:
GET /openapi.jsonon the admin listener is always on and cheap: the embedded bytes with a strong ETag.GET /docsis on the admin listener too, behindXI_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,/docsreturns 404 and says to point any OpenAPI viewer at/openapi.json. This covers air-gapped installs. - The public listener never serves either route.
-
URL dialects in OpenAPI. One operation per mounted dialect grammar, not per option.
- imgproxy is documented as
GET /{signature}/{processingOptions}/{source}.processingOptionsandsourceare documented asstyle: simplestrings 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.
/infogets the same treatment withImgproxyInfoOptionsand the realInfoResponsebody.ImgproxyProcessingOptionsis a JSON Schema object with one property per canonical option name (URL-DIALECTS §3.2). Each value is a positional-argument tuple (prefixItemswith typed, ranged and enumerated items), withx-xi-aliases,x-xi-milestoneandx-xi-proannotations.- The schema is generated from a declarative option catalogue in
internal/dialect/imgproxy, and a test checks the catalogue against the parser’sapplyswitch in both directions and against URL-DIALECTS §3.2.transform.Requestis not exposed. It is the normalised internal model: it holdsDialectandIgnored, carries unit-converted values, and changing it must not become an API break.
- imgproxy is documented as
-
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.StatusErroris written as it is, with its own status (huma.go, handler path:errors.As(err, &se), thenwriteResponseWithPanic). Ourerrs-backedErrorResponsecan 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-
StatusErrorerror are all built byWriteErr, which calls the package variableNewErrorWithContext, which defaults toNewError(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.ErrorModelbody intoErrorResponse. AResolverthat returns ourStatusErrorsets the status, because the lastStatusErrorwins. Per-operationSkipValidateParams/SkipValidateBodylet a handler validate for itself. Separately,WriteErrprints toos.Stderr, not slog, if writing an error fails. - The server model is safe.
humagoregistersMETHOD prefix+pathon our*http.ServeMuxand readsr.PathValue.http.Server, its timeouts, the recover middleware andMaxBytesReaderall 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,/readyzand one admin JSON route tohumago.NewWithPrefix(mux, …):- Every error body on those routes, including huma-generated ones (malformed input, failed validation, an unacceptable
Accept, a handler panic, an unexpected error), is ourErrorResponsewith the status thaterrsmaps its kind to. For example, invalid input is 400, not huma’s 422. The WP-82 contract tests pass unchanged. - 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. - No regression against go-standards §2: routes stay on our stdlib
ServeMux,http.Serverkeeps every timeout, and shutdown, recover andMaxBytesReaderare unchanged.go list -deps ./...shows no new third-party module in the build. huma writes nothing to stderr in any tested path. - The generated OpenAPI for the moved routes equals the WP-82 golden, or differs only by an explained, reviewed diff.
/healthzallocations 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
serverandobsas well. If it fails, huma stays builder-only, and the spike’s findings are recorded here. Either way, the image endpoints and/infostay 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. - Handler errors are ours. A handler error that implements
Consequences
Section titled “Consequences”- There is one new runtime dependency,
github.com/danielgtaylor/huma/v2(MIT), confined tointernal/apidocby 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.
apidocowns 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, plusImgproxyProcessingOptionsfor URL building. The first package is@xtandard/image-urlinpackages/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 publishopenapi-3.0.jsontoo. - The config JSON Schema (ADR 0007) stays a separate artefact. OpenAPI can
$refits published URL undercomponents.schemas.Configfor documentation, but the two are not generated by the same tool: config hasyamltags, and huma readsjson.
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/v2v2.39.1 (MIT, released 2026-07-29), builder only;github.com/santhosh-tekuri/jsonschema/v6v6.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).TestNoHumaGlobalsfails on any assignment to a huma package variable anywhere in the module. - Layout:
internal/apidoc(Build,Marshal,Spec) and the committed, embeddedinternal/apidoc/openapi.json(the file ADR 0009 and WP-84 read). It stays underinternal/becausego:embedcannot 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(wasinfoJSON),server.ErrorResponse+server.ErrorBody(theerrorJSONbody, built byserver.NewErrorResponse),errs.Kinds()for the kind enum.doc/minimum/maximumstruct tags feed the schema descriptions. - Routes:
server.Patterns()andadmin.Patterns(opts)list what each mux registers (the admin mux is built from one route table).TestRouteCoveragemaps both ways: every registered pattern is hit by a documented operation’s sample request, and every operation resolves on itsx-xi-listener.GET /config.schema.jsonis 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 rootx-xi-dialectsextension with their default prefix and URL-DIALECTS link. Error responses are shared components (Error400…Error504) generated from theerrstable plus the ingress 414 and the 501 of not-yet-built request kinds; theErrorResponsedescription carries the ARCHITECTURE §12 status table. - Option catalogue (WP-83):
internal/dialect/imgproxy/catalog.go(ProcessingOptions,InfoOptions) andschema.go(ProcessingOptionsSchema,InfoOptionsSchema,Option.Decode). Enumerations are read from the parser’s own tables. The schemas are plain JSON Schema maps, soimgproxydoes not import huma;apidocinserts them asImgproxyProcessingOptions/ImgproxyInfoOptions. Besidesx-xi-aliases,x-xi-milestoneandx-xi-pro, each option carriesx-xi-scope,x-xi-syntax,x-xi-defaultand, for security options,x-xi-security.catalog_test.gochecks the catalogue against theapply/applyLater/applyInfoswitches (parsed withgo/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 thespalias ofskip_processingandgifoofgif_options, which the parser already accepted. - Serving:
admin.Options.OpenAPI(fromapidoc.Spec, wired inapp) serves/openapi.jsonasapplication/jsonwith 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:falsekeeps fonts.scalar.com out).XI_ADMIN_DOCS_CDNwas not added: with the single flag off,/docsalready answers 404 with a pointer to/openapi.json, which covers air-gapped installs. - CI: the
openapijob runs pinnedbunx [email protected]andbunx @redocly/[email protected] linton the committed file. Redocly’sno-path-trailing-slashis skipped (pprof’s index pattern is/debug/pprof/); the rootsecurity: [{}]states that no HTTP authentication applies (signatures live in the URL, seex-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:
-
Error envelope and status: met, but only through huma internals. A
Config.Transformersentry rewrites*huma.ErrorModel(and aStatusErroradapter around*errs.Error) intoserver.ErrorResponseand setsContent-Type: application/json. A transformer cannot change the status: huma decides it first and callsctx.SetStatus(status)after the transformers (transformAndWriteinhuma.go). The spike therefore also installs a per-API middleware that wrapshuma.Context, lets the transformer store the errs status and substitutes it inSetStatus. 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 insidetransformAndWrite, which is not part of huma’s API; the tests would catch a change, but every huma upgrade would be a risk. -
No huma global assigned: met. Only
huma.DefaultJSONFormatis read.TestNoHumaGlobalsstays green. -
go-standards §2 and stderr: partly failed. The
ServeMux,http.Servertimeouts, shutdown, recover andMaxBytesReaderare untouched, and no new module enters the build (humagoandqueryparamare packages of the huma module we already require). But huma writes to stderr outside our control:WriteErrdoesfmt.Fprintf(os.Stderr, "could not write error: …")when writing an error body fails, andwriteResponseWithPanicpanics 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. -
Generated OpenAPI: failed without more wrapping.
huma.Registerdocuments every operation’s errors with the schema of whatever the package variablehuma.NewErrorreturns (defineErrors), i.e. huma’sErrorModelasapplication/problem+json, not ourErrorResponse. The spike had to mark the operationsHiddenand keepinternal/apidoc’s builder as the published document, which removes the benefit that motivated the spike (spec equals handler). Fixing it needs a customOperationDocumenterthat rewrites each operation’s responses. -
Allocations and latency: failed.
for b.Loop()benchmark ofGET /healthzthrough the mux with anhttptest.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.AllocsPerRungives the same 12 vs 17 for both/healthzand/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.