openapi

package
v1.801.307 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 28, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package openapi projects the LIVE zip/fiber router into an OpenAPI 3.1 document. The spec is not a description of the router — it IS the router, read through app.Fiber().GetRoutes() at request time. There is no checked-in spec file and no second route registry, so the document cannot drift: the only way to change it is to change the routes it is read from. (Register adds a registry of BODIES, never of routes: a declared schema renders only on a route the router carries, so the paths remain the router's alone.)

This mirrors the rule zapface/wire.go states for transports — two transports, ONE dispatch path. ZAP and OpenAPI are two PROJECTIONS of one route table.

Reading the router is not a workaround; it is the only total source

Two properties make the LIVE router the only honest source, and no static scan of the source tree a substitute:

  • Routes are composed at runtime. POST /v1/kms/auth/login is registered as Group("/v1/kms/auth").Post("/login") — that path literal does not exist anywhere in the tree, so no grep can find it. Only the assembled router knows it.
  • The route set is a function of DEPLOYMENT CONFIG. Subsystems mount only when cfg.Enabled(name), and several gate routes internally (kms registers its secret routes only `if kc != nil`). The spec therefore VARIES PER DEPLOYMENT, correctly: a deployment that does not mount admin does not advertise admin. That is a feature — each deployment describes itself — and it is why the document is generated per-process, not built once in CI.

Two readings of one router, folded into one document

zip ships its own OpenAPI projection (zip/openapi.go) driven by the typed-op registry (zip.Get[In, Out] → app.ops), which carries the real request/response Go types and therefore real JSON Schema — plus the prose cmd/zipdoc lifts out of the handlers' doc comments at build time. It is the better reading, and it is not duplicated here: Typed READS it and Fold lays it over the router projection.

The two are not rivals because GetRoutes() is a strict SUPERSET of app.ops (registerTyped registers a fiber route too). So the router gives the TOTAL set of operations and the registry gives DETAIL for the subset that has any:

router   → every operation exists, with its address and product.
registry → the typed ones also carry schemas, parameters, responses,
           descriptions and examples.

One document, no gaps and no invention. Migrating a raw handler to a typed op is what earns it the second half, and it needs no generator change: the same fold picks it up on the next run.

Register is the seam through which a subsystem DECLARES the payload types the router cannot derive. The projector (From) reads only two sources: the live route table, which says WHICH operations exist, and this registry, which says what a declared operation's bodies CONTAIN. The drift-proof property survives because the registry cannot add an operation: a registration whose route is not in the router simply never renders, so the document still cannot disagree with the router — schemas are additive metadata on routes that exist.

The schema itself is derived by reflection from the very Go structs the handler binds (json tags), stated once at the registration site next to the route. There is no hand-written schema to fall out of sync with the code: change the struct and the document follows.

Index

Constants

View Source
const Path = "/v1/openapi.json"

Path is the canonical spec endpoint. House law: /v1/ only, no /api/ prefix, and never a v2 — the document's own shape is versioned by its `openapi` field.

Variables

This section is empty.

Functions

func Fold added in v1.801.299

func Fold(doc *Document, reg Registry) error

Fold lays the registry's detail over the router's shape, in place. A typed op REPLACES the structural operation at its address — it is strictly richer, including the path parameters, which zip derives from the same pattern.

Two things the fold keeps from the router, both because the router is the authority on them:

  • the product tag, which is the path's first /v1/ segment and nothing else. zip's per-op tags are a different axis and cloud registers none.
  • membership. A typed op with no live route is a contradiction — registering one registers a fiber route — so it means the two readings disagree about a path (a translation bug), and the honest answer is to refuse rather than to invent the operation or drop it silently.

func Mount

func Mount(app *zip.App, info Info, servers ...Server)

Mount serves the document at Path off app's OWN live router — the app it is registered on is the app it reads, so what it serves is that process's actual route table and nothing else.

It is UNAUTHENTICATED, deliberately:

  • It is an API description of a public API, and it grants no capability. Every route it names stays individually auth-gated; reading the map does not open a door. Withholding it would be obscurity, not access control.
  • The `hanzo` CLI must build its command tree BEFORE a user logs in. Gating the spec would make `hanzo --help` require credentials.
  • It carries no schemas, no examples, no secrets — only addresses.
  • Enablement already scopes it: the document is generated from what THIS deployment mounted, so a deployment that does not enable admin does not list admin routes. The blast radius is the deployment's own surface.

The cost accepted is path enumeration on a deployment that mounts admin. That is a recon convenience, not an authorization change. If a deployment ever needs it closed, the lever is a guard here — one line, one place.

The document is built once, lazily, on first request: the route table is fixed after boot, and building lazily (rather than at Mount) means it includes every route — including this one, and any registered after Mount.

func Product

func Product(path string) string

Product is the product axis, and it is mechanical: the first path segment after /v1/ IS the product (/v1/kms/* → kms, /v1/billing/* → billing). No judgment, no table to maintain, nothing to keep in sync.

It is deliberately NOT the subsystem name: clients/billing serves both /v1/billing/* and /v1/finance/* (finance.go:57), so the mount that owns a route and the product a caller names are different values. The CLI wants the one in the URL.

Returns "" when the first segment is not a product name — a parameter (:org), a wildcard (*), or a file (openapi.json) — and for anything outside /v1 (/health, /.well-known/*, /git/*, /tasks/*). Those get no tag rather than a fabricated one.

func Register added in v1.801.299

func Register(path, method string, req, resp any)

Register declares the request and response body types for one route, keyed by the fiber pattern exactly as the route is registered. Pass the zero value of the handler's own binding struct (or a slice of the view type for list endpoints); pass nil for a side that has no body. Called from the owning subsystem's init, next to its route table.

A duplicate registration for the same (method, path) is a programming error at init time and panics loudly rather than letting two declarations race for one operation.

Types

type Components added in v1.801.299

type Components struct {
	Schemas map[string]any `json:"schemas,omitempty"`
}

Components holds the named schemas operations reference by $ref, so an SDK generator mints one named type per Go struct instead of an anonymous shape per operation. Open-typed: Register contributes *Schema values, the typed fold contributes zip's JSON Schema verbatim — both marshal to the same vocabulary.

type Conflict added in v1.801.299

type Conflict struct {
	Kind string // "operation" or "schema"
	Name string // "GET /v1/x" or the schema name
	A, B string // the two apps that claim it, in weave order
}

Conflict is two apps disagreeing about one name. Typed rather than a bare error string so a caller (and the gate) can assert on the KIND of collision without matching prose.

func (*Conflict) Error added in v1.801.299

func (c *Conflict) Error() string

type Document

type Document struct {
	OpenAPI    string              `json:"openapi"`
	Info       Info                `json:"info"`
	Servers    []Server            `json:"servers,omitempty"`
	Tags       []Tag               `json:"tags,omitempty"`
	Paths      map[string]PathItem `json:"paths"`
	Components *Components         `json:"components,omitempty"`
}

Document is an OpenAPI 3.1 document.

What IS derivable from the router, and what is NOT

The router is a dispatch table: pattern → handler. It knows how to MATCH a request, not what a request or response CONTAINS. Concretely, fiber's Route struct (fiber/v3@v3.2.1 router.go:46) is only {Method, Name, Path, Params, Handlers} — there is no payload information in it to read.

Derivable (asserted here, all of it structural):

  • method — the stack a route is registered in.
  • path — the registered pattern, verbatim.
  • path parameters — the pattern's own :name segments; a router that could not name them could not match them. Always required:true (a fiber path param is optional only if written :name?, which cloud does not use).
  • product tag — the first /v1/ segment of that same pattern.

NOT derivable from the router (no amount of router-reading changes it — these come from the typed registry via Fold, and are absent on a route that has no typed op):

  • request body schema. The router holds a func(*zip.Ctx) error. The request type is a LOCAL VARIABLE inside the handler body — clients/kms putSecret is the representative case: `var req secretPutRequest; json.Unmarshal( ctx.Body(), &req)`. The type exists in the package but never appears in the handler's signature, and Go has no reflection from a func value to the types it unmarshals internally. cloud.Handle[S] does not help: its type parameter S is the SERVICE (service.go:90), not the payload. cloud.Typed does not help either: it is an any→*zip.App mount adapter. What cannot be DERIVED can still be DECLARED: Register (register.go) is the seam a subsystem uses to state its binding structs once, next to its route table, and the schema is reflected from those structs.
  • response body schema / status codes — same dead end, at the far end, with the same declaration seam (the success shape under a "2XX" range, because the exact code lives in the handler body).
  • query and header parameters — read positionally via c.Query("k") at runtime; not part of the match, so the router has never heard of them.
  • auth requirements — enforced by middleware and by guards wrapped around handlers (guard(s, cloud.Handle(s, listSecrets))), invisible as data.
  • summaries/descriptions — prose that exists only in Go comments.
  • wildcard semantics — a fiber `*` matches MULTIPLE segments greedily; OpenAPI's {param} matches one. The emitted {wildcardN} is the closest honest approximation and is NOT equivalent.
  • whether a route is a real endpoint or a PROXY PREFIX. A catch-all like app.Post("/v1/billing/*") forwards to another service; the operations behind it (POST /v1/billing/deposit and friends) are not routes in this process and cannot appear. For products whose whole surface is one catch-all, this document can name the prefix and nothing under it.

The consequence for a consumer: this document is a complete and exact map of the API's SHAPE (every operation, its address, its product), and it describes PAYLOADS exactly as far as the typed registry does. A CLI can build its full command tree — `hanzo <product> <resource> <verb>` — and bind path params from it with no judgment calls, everywhere. It can typecheck a request body and pretty-print a response only for a typed op, and behind a catch-all it cannot enumerate subcommands at all.

The path to more schema is not a better reader. Two seams exist, both anchored in the handler's own Go types so neither can drift:

  • Register (register.go): a subsystem declares its binding structs for a route it already serves — no handler change, schema by reflection. A registration renders ONLY when the router carries the route, so the document still cannot disagree with the router; schemas are additive metadata on routes that exist.
  • zip's typed ops (zip.Get[In,Out]), which carry the In/Out types in the handler signature itself and also earn an MCP tool and a CLI command from the same registry entry (zip's third projection, zip/mcp.go). That is a per-handler refactor of business logic, and it composes with this: GetRoutes() already includes typed ops, so migration adds detail through the fold that is already running.

Chained handlers are not collisions

Fiber MERGES byte-identical route patterns into ONE Route carrying both handlers chained, so a duplicate registration is invisible to an entry count and shows only as len(Handlers) > 1. That is true — but the converse does not hold, and this generator does NOT use that signal:

app.Post("/v1/billing/auto-recharge/run-all",
    commercemid.RequestContext(), commercemid.TokenRequired(),
    commercemid.PlatformOnly(), commercebilling.RunAutoRechargeAllOrgs)

is ONE registration with FOUR handlers — three middleware and a terminal handler (apps/commerce.go:151). The whole /v1/store/* surface is the same shape. 34 live routes carry chained handlers and every one is legitimate. A merged duplicate and a middleware chain are INDISTINGUISHABLE through the public API, so "handlers > 1" cannot mean "collision" fleet-wide; treating it as one would refuse a spec for a healthy router. (clients/bots/routes_test.go asserts exactly that rule, and is right to: it is a local truth for the bots/visor/runtime surface, where nothing chains middleware. It is not a global one.)

This costs the document nothing. Merged or chained, one pattern is one operation — which is what the spec emits either way. Detecting routing bugs is the bots guard's job, at the seam where the premise holds; the spec's only requirement is that (method, path) → operation stay injective, which From enforces via operationId uniqueness.

func FleetSpec added in v1.801.299

func FleetSpec(app *zip.App) (*Document, error)

FleetSpec projects app into THE Hanzo Cloud API document: Spec, carrying the one identity above.

Every producer of a published spec calls this — the monolith's golden, each app binary's subset, and the weave's output. Spec stays exported for the callers that want a document of their own (the live endpoint names the deployment's brand), but nothing that writes an artifact should be choosing its own title.

func From

func From(rs []Route, info Info, servers ...Server) (*Document, error)

From builds the document from route data. No router, no I/O — its inputs are the routes, plus the package registry of declared bodies (Register), which is written only at init time and is therefore fixed by the time any document is built.

It refuses on a duplicate operationId rather than emit a document a generator would mis-consume. That check subsumes the only ambiguity the spec can suffer: two routes sharing a (method, path) derive the same id, so an injective (method, path) → operation map is exactly what uniqueness buys. It refuses equally when two DIFFERENT Go types claim one component name — a silent merge would hand an SDK generator a lie.

func Spec

func Spec(app *zip.App, info Info, servers ...Server) (*Document, error)

Spec reads the live router and both projections of it — the one call a caller wants.

func Weave added in v1.801.299

func Weave(parts []Part) (*Document, error)

Weave composes the parts into the fleet document, or refuses.

Identity (openapi version, info, servers) comes from THIS package, not from the parts: a subset is a partial view of one API, so the composed document is that same API — not a fifth one assembled out of whatever titles the parts happened to carry.

type Info

type Info struct {
	Title       string `json:"title"`
	Description string `json:"description,omitempty"`
	Version     string `json:"version"`
}

Info is the OpenAPI info block.

type Media added in v1.801.299

type Media struct {
	Schema *Schema `json:"schema,omitempty"`
}

Media is an OpenAPI media type object: the schema of one content type.

type Operation

type Operation struct {
	OperationID string      `json:"operationId"`
	Summary     string      `json:"summary,omitempty"`
	Description string      `json:"description,omitempty"`
	Tags        []string    `json:"tags,omitempty"`
	Parameters  []Parameter `json:"parameters,omitempty"`
	RequestBody any         `json:"requestBody,omitempty"`
	Responses   any         `json:"responses,omitempty"`
}

Operation is one operation.

Everything past Parameters is omitempty and comes from exactly two seams, both anchored in the handler's own Go types so neither can drift:

  • Register (register.go): a subsystem declares its binding structs for a route it already serves — schema by reflection, attached only when the router carries the route.
  • zip's typed ops, folded in from the registry (Fold): Summary/Description from the lifted godoc, query parameters and bodies from the In/Out types.

A route neither seam knows keeps exactly the structural facts the router can prove, and asserts no status code, content type or prose it has no evidence for — OpenAPI 3.1 makes `responses` OPTIONAL (3.0 required it), so absent stays valid and absent beats invented.

RequestBody and Responses are `any` because the two seams produce different (JSON-identical) shapes: Register builds the closed *RequestBody / map[string]*Response, the fold reuses zip's open maps verbatim.

type Parameter

type Parameter struct {
	Name        string         `json:"name"`
	In          string         `json:"in"`
	Required    bool           `json:"required"`
	Description string         `json:"description,omitempty"`
	Schema      map[string]any `json:"schema,omitempty"`
}

Parameter is an OpenAPI parameter object.

From the ROUTER only path parameters are emitted: they are structural — the router matches on them — so they are derivable. From the REGISTRY a typed op's query parameters come too, with descriptions, because its input type says what they are. Schema is an open map because JSON Schema is an open vocabulary: the router asserts `{"type": "string"}` and nothing more, while a typed op's field can be any shape zip derives from its Go type.

type Part added in v1.801.299

type Part struct {
	App string
	Doc *Document
}

Part is one app's contribution: its document, and the name a conflict is reported against. The name is the app's, not the file's — a message naming two paths a human then has to map back to apps is a worse message.

type PathItem

type PathItem map[string]*Operation

PathItem maps a lowercased HTTP method to its operation.

type Registry added in v1.801.299

type Registry struct {
	Ops     map[string]*Operation
	Schemas map[string]any
}

Registry is zip's typed-op projection, reduced to what the router cannot supply: the operations that carry schema and prose, keyed by the same "METHOD /templated/path" identity a Document is indexed by, and the schemas they $ref.

func Typed added in v1.801.299

func Typed(app *zip.App) (Registry, error)

Typed reads the typed-op registry off the app — the SAME value zip serves at /.well-known/openapi.json, so there is one generator for schemas and this is not it.

It comes back through JSON rather than by walking zip's map[string]any because the map IS a JSON document; decoding it into the same Operation the router projection builds is what makes the two foldable at all, and it means a field zip adds later arrives here without a change (or is dropped honestly, if nothing here has a name for it).

type RequestBody added in v1.801.299

type RequestBody struct {
	Content map[string]Media `json:"content"`
}

RequestBody is an OpenAPI request body object.

type Response added in v1.801.299

type Response struct {
	Description string           `json:"description"`
	Content     map[string]Media `json:"content,omitempty"`
}

Response is an OpenAPI response object. Description is required by the spec.

type Route

type Route struct {
	Method string
	Path   string // fiber pattern, e.g. /v1/kms/orgs/:org/secrets/*
}

Route is one live route, reduced to what the router actually knows: where a request goes. Method and Path are the whole of it.

There is deliberately no handler count here. See Document's note on chained handlers for why that number cannot be interpreted.

func Live

func Live(app *zip.App) []Route

Live reads the router. It is the SOLE adapter from fiber to data — every other function here is a pure function of []Route, so the projection is testable without a router and the fiber coupling has exactly one home.

GetRoutes(true) drops Use() middleware entries: middleware matches path PREFIXES and is not an operation. That filter is fiber's own (app.go:822), not a reimplementation of it.

type Schema

type Schema struct {
	Type                 string             `json:"type,omitempty"`
	Ref                  string             `json:"$ref,omitempty"`
	Items                *Schema            `json:"items,omitempty"`
	Properties           map[string]*Schema `json:"properties,omitempty"`
	AdditionalProperties *Schema            `json:"additionalProperties,omitempty"`
}

Schema is the sliver of JSON Schema this generator can honestly assert as a CLOSED struct: primitive types for path parameters, and — for routes whose subsystem declared its bodies via Register — objects, arrays, and $refs derived by reflection from the handler's own binding structs. Typed-op schemas do NOT pass through it: zip already derives arbitrary JSON Schema from the In/Out Go types, and restating that open vocabulary here would be a second, lossier copy — they travel as `any` (see Fold).

type Server

type Server struct {
	URL string `json:"url"`
}

Server is an OpenAPI server entry.

type Tag

type Tag struct {
	Name string `json:"name"`
}

Tag is an OpenAPI tag — one per product, so a consumer can read the product list off the document without walking every path.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL