Status: Accepted Date: 2026-06-02
v0.1 — v0.2 ship a typed Go → typed TS pipeline. Non-Go/TS consumers (mobile teams, Postman users, internal docs platforms, OpenAPI-driven test tools) have no entry point. The roadmap promised OpenAPI 3.1 export for v0.3.
The IR is already shaped right for the job. Every ir.Route has a
method, path, param tables, body/response type refs, doc, success
status, tag. Every ir.TypeDef has a wire shape (struct/enum/alias),
fields, type params (per ADR 0033). The OpenAPI generator is the
fifth-and-a-half IR consumer alongside the four TS generators and
goadapter; per ADR 0022 §1 it
implements the same Generate(api *ir.API, w io.Writer) error.
OpenAPI 3.1 accepts JSON or YAML. JSON keeps the dep story clean
(encoding/json is stdlib; YAML would add a parser/emitter for one
generator's output). Tooling that wants YAML can convert downstream
(yq, Spectral, Stoplight Studio, etc. all consume JSON natively).
Filename: openapi.json in --out — matches the existing TS-
generator pattern (types.ts, schemas.ts, etc.). New CLI flag
--openapi. --all includes it.
info.titleis the package name (gen.PackageName(api)); a v0.4 surface could add--openapi-titleetc.info.versionis"0.0.0"for v0.3 — goduct doesn't know the user's release version. A--openapi-versionflag is a follow-up.pathsare grouped by route path (multiple routes sharing/users/{id}collapse under one entry withget/patch/deletekeys). Order: alphabetical (deterministic, no per-route position dependency).components/schemascarries every IR type plus the synthesizedGoductError.
Goduct uses :id-prefixed path params (same as gin/echo). OpenAPI
mandates {id} brace syntax. Convert at emit time — same one-liner
that goadapter's chi/mux entries use.
Each operation gets:
tags:[route.Tag]— single-element array.operationId:route.HandlerName(e.g.GetUser).summary: first sentence ofroute.Docviagen.JSDoc.description: full doc viagen.JSDocFull(when more than one sentence; omitted otherwise so single-sentence docs don't show up twice).parameters: path / query / header params.requiredper ADR 0015 (path always required; query/header perParam.Optional).schema: the parameter's wire type (string / integer / etc.).requestBody: present whenroute.BodyType != nil. Content typeapplication/json, schema$refto the body component.required: true.responses:- The success status (
200/201/204):- For non-204:
application/jsoncontent withschema: $ref. - For 204: empty body (
{"description": "No Content"}).
- For non-204:
default:application/jsonwith$reftoGoductError— covers the runtime's loud-failure path for any 4xx/5xx.
- The success status (
Every ir.TypeDef → one components/schemas/<Name> entry.
| IR kind | OpenAPI schema |
|---|---|
Builtin string, time.Time, []byte, uuid.UUID |
{"type": "string"} (with format: date-time / byte / uuid where applicable) |
Builtin bool |
{"type": "boolean"} |
Builtin int/int*/uint*/float*/time.Duration |
{"type": "integer"} or {"type": "number"} |
Builtin json.RawMessage |
{} (no type constraint — JSON Schema 2020-12 "any") |
| Named (struct) | {"$ref": "#/components/schemas/<Name>"} |
| Slice | {"type": "array", "items": <inner>} |
| Map | {"type": "object", "additionalProperties": <value>} |
TypeParam (KindTypeParam) |
NOT emitted at this layer — see §6 generics |
Struct fields with oneof validators |
{"type": "string", "enum": [...]} |
Struct fields with min/max/len |
adds minLength/maxLength/minimum/maximum etc. |
| Custom adapter (ADR 0032) | wire-shape table: string → {"type": "string"}, etc. |
required array on struct schemas: every non-optional wire-visible
field (mirrors zod's .optional() rule). Optional fields appear in
properties without being listed in required.
OpenAPI has no generic-schema concept. Each distinct instantiation becomes its own component schema:
*Page[User]→ component schemaPage_User.*Result[User, Err]→ component schemaResult_User_Err.*Page[Result[User, Err]]→ component schemaPage_Result_User_Err(transitively).
Naming: underscore-joined (Page_User). OpenAPI identifiers
can't contain [ ] < > ,; underscore is the conventional
escape used by OpenAPI generators across languages. The generic
origin (Page without args) is not emitted as a standalone
schema — every reference is to an instantiated form.
When the generator walks api.Types, generic origins (TypeParams != nil) are visited but NOT emitted as a top-level component. Instead,
the analyzer's existing api.Types walk surfaces both origins and
the args; the OpenAPI generator collects every distinct instantiation
seen across Route.RequestType / BodyType / ResponseType (and
their nested TypeArgs) and synthesizes the substituted schema for
each.
Field types referencing KindTypeParam are substituted with the
concrete arg at substitution time — never emitted as a literal "T".
A synthetic component schema is added to components/schemas:
"GoductError": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"details": {}
}
}Every operation's default response refs it. The schema is NOT
derived from an IR type — it's a hardcoded reflection of the
runtime's goduct.Error shape (ADR 0004). If a user has their own
GoductError type in their package, it gets a different component
name (the user's would be <pkg>.GoductError short-named); the
synthesized one always lives at components/schemas/GoductError.
A real-world collision (user defines a type literally named
GoductError) is a v0.4 concern; v0.3 emits both and lets the
user-side win on lookup ordering (last-write wins). Tracked as a
TODO if it ever bites.
paths: alphabetical.paths[x].<method>: HTTP-method canonical order (get,put,post,delete,options,head,patch,trace).components/schemas: alphabetical.propertieswithin a schema: source-declaration order (matches theWireFieldsorder the TS generators use).parameters: declaration order (path, then query, then header, each in declaration order).requiredarrays: alphabetical (canonical order; readers don't depend on declaration order).
encoding/json.MarshalIndent sorts map keys alphabetically for free,
so the alphabetical orderings are zero-cost. The HTTP-method
canonical order needs a small explicit map (one place to maintain).
properties source order can't be a Go map — must be []Property
encoded as a custom MarshalJSON, or a json.RawMessage assembly.
For implementation simplicity v0.3 uses json.RawMessage
assembly for the path-operation map, the schema-properties map,
and the schemas map. The rest of the doc uses ordinary maps with
alphabetical encoding/json sorting. This trades a bit of code-shape
clarity for not introducing a custom MarshalJSON contract on every
embedded type.
The OpenAPI spec describes the API surface, not how it's wired. The
output is identical regardless of --framework chi|gin|echo|mux.
The path conversion uses OpenAPI's {id} syntax in all cases.
- YAML output. Convert with
yq/jqif needed. - OpenAPI security schemes (Bearer, OAuth, API keys). v0.4
add
--openapi-securityor similar. - Multiple server URLs (
serversarray). v0.3 omits theserversfield; clients default to the current origin. - Per-route status overrides. A handler that explicitly returns
404(viagoduct.NotFound) is documented only via the synthesizeddefaultGoductError response, not a404entry. Status-code-aware emission (responses["404"]) is a v0.4 polish. - Per-field examples. A
--openapi-examplesflag could opt in later if real users surface a need. - OpenAPI
infoenrichment. Title is the package name, version is"0.0.0". Custom--openapi-title/--openapi-version/--openapi-descriptionflags are TODO follow-ups.
Easy / unblocked:
- Mobile clients, Postman users, internal docs platforms, OpenAPI- driven test tooling all get a feed from the same Go source.
- Swagger UI and Postman collection generators are thin downstream consumers of this output (each is ~hours of work, not its own ADR-worthy session).
- Schema-level features (oneof → enum, min/max/len, custom adapters, generics-as-flattened-instantiations) come free from the existing IR.
Hard / giving up:
- Generic instantiations explode the schema component count when an
API has many distinct uses. A v0.4
$ref-with-allOf composition could compress this; v0.3 emits the flat form and accepts the schema-count growth. - The GoductError synthesized component collides with a user-defined
GoductErrortype (rare). Tracked as TODO. info.versionis hardcoded"0.0.0"; tools that gate on version will see no movement until--openapi-versionships.- Determinism via
json.RawMessageassembly is more code than lettingencoding/jsonorder maps alphabetically everywhere. We trade clarity for not breaking property declaration order, which is what users expect.
- YAML output by default — rejected for v0.3. Adds a parser/ emitter dep for one generator's output; tooling that wants YAML converts downstream.
- Per-instantiation generic naming as
PageOfUser— rejected. Underscore-joined is the more common OpenAPI-generator convention; it's also easier to disambiguate multi-arg (Result_User_ErrvsResultOfUserOfErris a mouthful). $ref+allOfcomposition for generics — rejected for v0.3. Most OpenAPI consumers don't fully implement JSON Schema 2020-12 composition; flat instantiations are universally supported.- Emit generic origins as schemas with TypeParam refs as
$ref: "#/T"— rejected. Not legal JSON Schema; tools choke. - Per-status-code responses derived from goduct.Error helpers
(404 from NotFound, etc.) — rejected for v0.3. The handler
signature doesn't declare which error helpers it uses; static
analysis would need to walk handler bodies. Synthesized
defaultresponse covers the contract. - Custom MarshalJSON for every schema/operation type to enforce
property order — rejected.
json.RawMessageassembly at the three places that need ordered keys is less code overall.
- 0004 —
{code, message, details?}shape of GoductError, mirrored in the synthesized component. - 0006 — validator → JSON Schema keyword mapping (min/max/len/email/oneof).
- 0014 — handler params / return shape; the OpenAPI operation builder reads these.
- 0015 —
Param.Optionaldrivesrequiredon OpenAPI parameter objects. - 0017 / 0032 — built-in + custom-adapter type-to-wire mapping; OpenAPI schema emission reuses the wire-shape table.
- 0022 §1 / §3 — generator signature, determinism rules.
- 0033 — generic recognition; OpenAPI emits flattened per-instantiation schemas rather than parametric forms.
{ "openapi": "3.1.0", "info": { "title": <packageName>, "version": "0.0.0" }, "paths": { "/users": { "get": {...}, "post": {...} }, "/users/{id}":{ "get": {...}, "patch": {...}, "delete": {...} } }, "components": { "schemas": { "User": {...}, "GoductError": {...}, ... } } }