Files
zip/openapi.go
zeekay 5085333c13 Keep every existing op's wire and close the new kinds' edges
A JSON answer keeps fiber's application/json; charset=utf-8 unless the
op names another media, and every declared status carries the Out's
schema again, a union's alternative filed under the status it states.
A value of the op's own type stating status zero is refused as before;
only Body and Redirect read zero as the op's own. A header field is a
CLI flag under its field's name as it was, and a 3xx with a body is an
answer the remote CLI prints.

form: tags bind a form only when the op consumes a form media type, so
another binder's tags no longer move an op off JSON or refuse a GET.
A cookie: field is set by the request's cookie and nothing else, and no
body schema lists it, so a cross-site body cannot fill it.

Sse.Send takes a ctx that ends with the stream: a tool call collects
for at most toolWait and toolBound bytes, then cancels the producer,
and a stuck producer cannot hold the call. A panicking Reader ends its
answer, not the process. A socket op refuses a request that cannot
upgrade before its handler runs, over REST and the call plane, and a
nil CheckOrigin is same-origin, refused 403. The call plane carries a
union's JSON alternative; Sse and Socket fields that cannot cross say
so with zap:"-". MCP bytes are bounded and untyped bytes take the op's
media.
2026-10-09 09:42:01 -07:00

1247 lines
47 KiB
Go

package zip
import (
"encoding/json"
"maps"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/jsontag"
)
// SpecPath and DocsPath are where an app serves its own OpenAPI document and
// the interactive page over it. Both are zip's control plane — a projection of
// the app, not a door its owner wrote — so neither appears in a [Declaration]
// and a host serves its own.
const (
SpecPath = "/.well-known/openapi.json"
DocsPath = "/docs"
)
// OpenAPIConfig configures the auto-generated /.well-known/openapi.json
// endpoint zip serves when typed handlers are registered.
type OpenAPIConfig struct {
// Title appears in the OpenAPI info block.
Title string
// Description appears in the OpenAPI info block.
Description string
// Version appears in the OpenAPI info block (e.g. "v1.0.0").
Version string
// Disabled suppresses the /.well-known/openapi.json route and /docs.
Disabled bool
}
// installOpenAPIRoutes wires /.well-known/openapi.json and /docs.
// Called from Listen / Serve. Idempotent if there are no typed ops.
func (a *App) installOpenAPIRoutes() {
if a.cfg.OpenAPI.Disabled || len(a.Registry()) == 0 {
return
}
spec := a.buildOpenAPI()
specJSON, _ := jsonenc.Marshal(spec)
a.control(fiber.MethodGet, SpecPath, func(fc fiber.Ctx) error {
fc.Set("Content-Type", "application/json")
return fc.Send(specJSON)
})
a.control(fiber.MethodGet, DocsPath, func(fc fiber.Ctx) error {
fc.Set("Content-Type", "text/html; charset=utf-8")
return fc.SendString(swaggerHTML)
})
}
// buildOpenAPI walks the registered typed ops and builds an OpenAPI 3.1
// spec as a plain map (json.Marshal serializes anything map-shaped).
func (a *App) buildOpenAPI() map[string]any {
return ProjectOpenAPI(a.Manifest())
}
// The schema names a refusal body is published under. Neither can be the name
// of a Go type: a type name has no '-', and a qualified one contains a '.'.
const (
problemSchema = "problem-details"
oauthSchema = "oauth-error"
)
// refusalResponse is an op's `default` response: an RFC 9457 problem document,
// or RFC 6749's error pair at an OAuth address. A relay's refusal may also be
// the upstream's own, relayed as sent ([Verbatim]), which the response says
// beside this service's.
func refusalResponse(reg *schemaRegistry, oauth, relayed bool) map[string]any {
name, media, schema := problemSchema, mimeProblem, problemDocument
if oauth {
name, media, schema = oauthSchema, mimeJSON, oauthError
}
if _, ok := reg.defs[name]; !ok {
reg.defs[name] = schema()
}
out := map[string]any{
"description": "refused",
"content": map[string]any{media: map[string]any{"schema": reg.ref(name)}},
}
if relayed {
out["description"] = "refused, by this service or by the upstream, whose refusal is relayed as it was sent"
out["content"].(map[string]any)["*/*"] = map[string]any{"schema": map[string]any{}}
}
return out
}
// problemDocument is the shape [HTTPError.problem] writes. A refusal's own
// extension members sit beside these, which an object schema allows by default.
// additionalProperties is left out rather than written as true: readers of the
// document, CommandsFromSpec among them, parse every schema as an object.
func problemDocument() map[string]any {
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
return map[string]any{
"type": "object",
"description": "An RFC 9457 problem document.",
"properties": map[string]any{
"type": str("about:blank"),
"title": str("The status's reason phrase."),
"status": map[string]any{"type": "integer", "format": "int64", "description": "The HTTP status."},
"detail": str("What was refused, for a person to read."),
"code": str("A stable token a client can branch on, when the refusal names one."),
},
"required": []string{"type", "status", "detail"},
}
}
// oauthError is the shape [HTTPError.oauth] writes (RFC 6749 §5.2).
func oauthError() map[string]any {
return map[string]any{
"type": "object",
"description": "An RFC 6749 error response.",
"properties": map[string]any{
"error": map[string]any{"type": "string", "description": "The RFC 6749 error code, such as invalid_request or invalid_grant."},
"error_description": map[string]any{"type": "string", "description": "What was refused, for a person to read."},
},
"required": []string{"error"},
}
}
// ID is an operation's name, derived from its method and its ABSOLUTE path.
//
// It is THE rule for that derivation and the only one. The OpenAPI operationId,
// the MCP tool name, the key the by-name call plane resolves and the command the
// CLI spells all read this function, so one operation carries one token on every
// surface and an agent reading the document can call what it just read.
//
// '_' is the SEPARATOR — it encodes '/' — so any character that also folded to
// '_' would collide with a path boundary. That is not hypothetical: a router
// serving both /v1/pricing-policy and /v1/pricing/policy, under an earlier
// "everything non-alphanumeric becomes _" rule, collapsed the two onto one id.
// Hyphenated addresses are permanent (…/git-upload-pack, …/documents/delete-batch),
// so '-' and '.' — both legal in an operationId — are PRESERVED rather than
// folded, which keeps such a pair distinct: get_pricing-policy is not
// get_pricing_policy.
//
// A parameter contributes "by_<name>", so /v1/a/{b} and /v1/a/b do not collapse
// either. Every spelling of one parameter reaches the same name: the router's
// (":id", and the constraint and optional marker that are matching business and
// not naming — see [Template]), the document's ("{id}"), and a wildcard, which
// declares no name and takes the positional one the document gives it.
//
// This is derivation, not proof: a literal '_' in a segment can still alias a
// '/' (/v1/a/b_c against /v1/a/b/c). The walk VERIFIES uniqueness across the
// whole program and refuses to publish a duplicate — that check, not this
// encoding, is what makes the ids trustworthy.
//
// THE LEADING v1 IS DROPPED, because it distinguishes nothing. A segment earns
// its place in an id by telling two operations apart, and this one is on every
// address in the program: it is a constant, and a constant repeated 2,000 times
// is noise carried by four surfaces at once — the document, the tool name an
// agent reads on every turn, the by-name key and the CLI's spelling.
//
// Only v1 goes. A later version is the EXCEPTION and keeps its segment, which
// is both how it stays honest and how it stays unique: get_agents is /v1/agents
// and get_v2_agents is /v2/agents, so the two cannot collapse onto one name the
// walk would then refuse. The default is implicit; a departure from it is not.
func ID(method, path string) string {
var b strings.Builder
b.WriteString(strings.ToLower(method))
stars := 0
first := true
for _, seg := range strings.Split(path, "/") {
if seg == "" {
continue
}
if first {
first = false
if seg == defaultVersion {
continue // says nothing: every address carries it
}
}
b.WriteByte('_')
switch {
case seg == "*" || seg == "+":
stars++
seg = "wildcard" + strconv.Itoa(stars)
b.WriteString("by_")
case strings.HasPrefix(seg, "{"):
// The document's spelling of what paramName reads in the router's.
seg = paramName(":" + strings.Trim(seg, "{}"))
b.WriteString("by_")
case strings.HasPrefix(seg, ":"):
seg = paramName(seg)
b.WriteString("by_")
}
b.WriteString(sanitize(seg))
}
return b.String()
}
// defaultVersion is the version every address is expected to carry, and so the
// one [ID] leaves out of a name. It is a value here rather than a rule spread
// across callers: one place decides what "unversioned by default" means.
const defaultVersion = "v1"
// sanitize reduces a path segment to [a-z0-9.-], the characters that are legal
// in an operationId and cannot be confused with the '_' path separator.
func sanitize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
// hasBody reports whether a method carries a JSON request body ON THE WIRE. It
// is the ONE place that rule lives, and since v1.18.0 it is the only place:
//
// - typed.go reads the request body only when it says so;
// - openapi.go declares a requestBody, else query parameters;
// - cli.go's Remote.Invoke sends a body, else a query string;
// - cli.go's bindIn offers flags for what that shape can actually carry.
//
// It is about the WIRE, not about the op. op.invoke always decodes the JSON it
// is handed, because addressing an op by name (MCP tools/call, zip.Call, a
// command) has no URL to carry half the input in — there the arguments object
// IS the whole input, whatever the method would have done over HTTP.
//
// DELETE carries no body. It used to, in the route and in the CLI's remote
// invoker, while the document said it did not — so a typed Delete read an input
// no generated client would ever send. Three spellings, one of them the
// document's; they are now one predicate.
func hasBody(method string) bool {
switch method {
case "GET", "HEAD", "DELETE":
return false
}
return true
}
// hasRequestBody reports whether an op PUBLISHES a request body: its method
// carries one, its input has a name to describe, and the input has at least one
// field the URL does not already carry.
//
// That last clause is the one that was missing. POST /v1/things/:id/verify binds
// its whole input from the path, so the requestBody the document declared had
// exactly one property — the path param — and it was marked required. Every SDK
// generated from that document gained a phantom argument: an object the caller
// must construct, to repeat a value it already passes in the URL.
//
// It is a refinement OF [hasBody], not a rival to it. The wire is untouched: the
// route still reads the body for a method that carries one, and bindURL binds the
// path last, so a body that repeated a path param never won anyway. The only
// thing that changes is what the document, and therefore every client generated
// from it, believes it has to send.
func hasRequestBody(op *registeredOp) bool {
if !hasBody(op.Method) || typeName(op.InType) == "" {
return false
}
t := op.InType
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return true // the body IS the whole value — a list, a raw message
}
named := map[string]bool{}
for _, p := range pathParams(op.Path) {
named[strings.ToLower(p.Name)] = true
named[strings.ToLower(p.Key)] = true
}
// wireFields, not NumField: an embedded struct's fields ARE in the body, and
// asking the outer type alone declared no body for an input whose own fields
// were all path params — the phantom body's mirror image.
for _, f := range wireFields(t) {
// A field is in the body when `json:` puts it there, and is already carried
// when `url:` binds it to a segment the route matched on. The two tags
// answer two questions, so both are asked.
if jsonFieldName(f) == "-" {
continue
}
if !named[strings.ToLower(urlFieldName(f))] {
return true
}
}
return false
}
// urlField is one URL-bindable input field: the name a caller writes in the URL,
// the Go field it binds to, the schema of the value, and whether the handler
// refuses to run without it.
type urlField struct {
name string
// field is the name the field carries on the WIRE, which is the key zipdoc
// files its doc comment under. It is not always the URL name — a wildcard
// binds under fiber's *N — so looking prose up by the URL name finds nothing
// for exactly the segment that carries the address.
field string
schema map[string]any
required bool
}
// urlSchema is the schema of what a URL carries into a field of type t, and nil
// when it carries nothing.
//
// It is [setScalar]'s rule stated for the document, and the ONE place the two
// are held together: the binder fills exactly what this names, so a parameter
// that does nothing is never published and one that works is never left out.
// The same question was answered by a second kind switch here, and the second
// one drifted — an id bound by [setText] was described by neither.
//
// A type that reads itself from text is a STRING here whatever it is made of.
// An id is [32]byte and a time is a struct, and each is one word in the URL and
// the same word in the JSON body beside it; schemaOf describes what a value is
// MADE of, which for these is not what it looks like.
func urlSchema(t reflect.Type) map[string]any {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if readsText(t) {
return map[string]any{"type": "string"}
}
switch t.Kind() {
case reflect.String, reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return schemaOf(t, nil, nil)
case reflect.Slice:
// A []byte with no written form of its own is one base64 string, which
// the binder does not split and cannot read — so nothing is published.
if t.Elem().Kind() == reflect.Uint8 {
return nil
}
if item := urlSchema(t.Elem()); item != nil {
return map[string]any{"type": "array", "items": item}
}
}
return nil
}
// urlFields lists the fields of an op's input a URL fills — the exact set
// bindURL fills, whether the value arrives as a path segment or a query key.
// ONE list serves both because it is ONE binder: a path param and a query param
// are the same kind of value, so the document describes them from the same
// place. What the binder cannot fill is omitted, because naming it would
// promise a parameter that silently does nothing.
func urlFields(t reflect.Type) urlFieldList {
var out urlFieldList
collectURLFields(t, "", map[reflect.Type]bool{}, &out)
return out
}
// urlRecord reports whether a URL names t's LEAVES through it rather than carrying
// t itself. A struct is that, unless it has a written form of its own — a time
// and an id are one word in a URL, not a pair of braces — in which case
// [urlSchema] already describes it and there is nothing to reach inside.
func urlRecord(t reflect.Type) bool {
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t != nil && t.Kind() == reflect.Struct && !readsText(t) && !isMarshaler(t)
}
// collectURLFields appends t's URL-borne leaves, naming a record's own leaves
// through it. inside is the record already being walked, so a self-referential
// input names its leaves once rather than forever: the binder stops at the keys
// a caller actually wrote, and a document walk has no keys to stop at.
func collectURLFields(t reflect.Type, prefix string, inside map[reflect.Type]bool, out *urlFieldList) {
if t == nil {
return
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct || inside[t] {
return
}
inside[t] = true
defer delete(inside, t)
for _, f := range wireFields(t) {
name := urlFieldName(f)
if name == "-" {
continue // on the wire, but not in the URL.
}
name = prefix + name
if urlRecord(f.Type) {
collectURLFields(f.Type, name+".", inside, out)
continue
}
schema := urlSchema(f.Type)
if schema == nil {
continue // on the wire, but not something a URL carries.
}
*out = append(*out, urlField{
name: name,
field: jsonFieldName(f),
schema: schema,
// The same `validate:"required"` that makes a body field required in
// its schema makes a URL-borne one required in its parameter. The
// handler refuses the request either way; a document that called it
// optional was describing a call that cannot succeed, and every
// generated client made the argument optional to match.
required: strings.Contains(f.Tag.Get("validate"), "required"),
})
}
}
type urlFieldList []urlField
// docKey is the wire name of the field bound under the given URL name, which is
// the key zipdoc files its doc comment under. The two differ only where a `url:`
// tag renames the binding — a wildcard's *N being the case that matters. Falls
// back to the URL name, which is right whenever they coincide and is all there is
// to try for a field that carries no wire name at all.
func (l urlFieldList) docKey(name string) string {
for _, f := range l {
if strings.EqualFold(f.name, name) && f.field != "" && f.field != "-" {
return f.field
}
}
return name
}
// paramSchema is the schema of the field a URL-borne name binds to, matched the
// way bindURL matches it. A name the input does not declare is a string: the
// router still matches that segment and the wire still carried text, and bindURL
// drops it for the same reason the document cannot type it — there is no field.
func (l urlFieldList) paramSchema(name string) map[string]any {
for _, f := range l {
if strings.EqualFold(f.name, name) {
return f.schema
}
}
return map[string]any{"type": "string"}
}
// pathParam is one segment the ROUTE matches on, under both the names it goes by.
//
// The two differ for a wildcard and only for a wildcard: the document calls the
// segment wildcardN, because that is what [Template] writes into the path and a
// template variable must be declared under the name the path uses; the router
// calls it *N, which is fiber's own capture key and therefore what an input's
// `url:` tag has to say to bind it. Carrying both together is what stops one being
// mistaken for the other — declared under Key, the document would name a parameter
// no path contains; bound under Name, the input would bind nothing.
type pathParam struct {
// Name is the document's spelling, and the name the parameter is declared by.
Name string
// Key is the router's spelling, and what `url:` binds against.
Key string
// At is which segment of the pattern this is, so a caller substituting a
// value into the address replaces the segment it found rather than searching
// for a spelling — the only one that works for a wildcard, whose segment is
// "*" and matches no name.
At int
}
// pathParams lists the segments a fiber route pattern matches on, in order —
// every `:name` and every wildcard.
//
// The two names a wildcard goes by are counted on DIFFERENT sequences, which is
// the whole reason both are carried. The document's name is positional, exactly as
// [Template] numbers it, so a declaration made here lands on the brace written
// there. The router's key is fiber's own, and fiber counts each marker separately
// and keeps the marker in the key — measured: `/p/+/q/*` matches under `+1` and
// `*1`, and `/p/+/q/+` under `+1` and `+2`. One counter for both would name a `+`
// route's capture `*1`, which binds nothing and suppresses no query parameter.
//
// It read `:name` alone until a wildcard could be a typed op: after that the path
// carried a {wildcardN} nothing declared, and the value the address IS went
// undeclared or arrived as a query parameter.
func pathParams(path string) []pathParam {
var out []pathParam
seen := map[byte]int{}
all := 0
for i, seg := range strings.Split(path, "/") {
switch {
case seg == "*" || seg == "+":
all++
seen[seg[0]]++
out = append(out, pathParam{
Name: "wildcard" + strconv.Itoa(all),
Key: seg + strconv.Itoa(seen[seg[0]]),
At: i,
})
case len(seg) > 1 && seg[0] == ':':
name := paramName(seg)
out = append(out, pathParam{Name: name, Key: name, At: i})
}
}
return out
}
// typeName is the name a type was declared under, which is usually its Go name.
//
// A type built at run time has no Go name — reflect makes no named types — so a
// struct that [ReadZAP] derived from a .zap declaration carries the name it was
// declared under on its fields instead, and this is where that is read. Every
// projection asks this rather than reflect, so a schema-derived type is named in
// the SDK, the document, the tool list and the schema written back out by the
// name its author gave it, and not by the Go spelling of its shape.
func typeName(t reflect.Type) string {
if t == nil {
return ""
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if n := t.Name(); n != "" {
return n
}
if t.Kind() == reflect.Struct && t.NumField() > 0 {
return t.Field(0).Tag.Get("decl")
}
return ""
}
// componentName is the name a type is published under: its own, except an
// instantiated generic. reflect names one Envelope[github.com/x/y.Store], which
// no schema name may contain, so it is published as the generic's name followed
// by its arguments' names — EnvelopeStore. A pointer argument is its element, a
// slice argument its element's name and List, a map argument its value's name
// and Map, a basic type its name capitalised.
//
// Prose stays filed under the reflect name, which is what zipdoc writes and
// what the field lookups read, so this changes what a schema is called and
// nothing about what it says.
func componentName(t reflect.Type) string { return plainName(typeName(t)) }
// plainName is a reflect type name with its type arguments folded in.
func plainName(n string) string {
open := strings.IndexByte(n, '[')
if open < 0 || !strings.HasSuffix(n, "]") {
return n
}
var b strings.Builder
b.WriteString(n[:open])
for _, arg := range typeArgs(n[open+1 : len(n)-1]) {
b.WriteString(argName(arg))
}
return b.String()
}
// typeArgs splits a type-argument list at its top-level commas.
func typeArgs(s string) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '[', '(', '{':
depth++
case ']', ')', '}':
depth--
case ',':
if depth == 0 {
out = append(out, s[start:i])
start = i + 1
}
}
}
return append(out, s[start:])
}
// argName is one type argument's part of a published name.
func argName(s string) string {
s = strings.TrimSpace(s)
switch {
case strings.HasPrefix(s, "*"):
return argName(s[1:])
case strings.HasPrefix(s, "[]"):
return argName(s[2:]) + "List"
case strings.HasPrefix(s, "map["):
depth := 0
for i := 3; i < len(s); i++ {
switch s[i] {
case '[':
depth++
case ']':
if depth--; depth == 0 {
return argName(s[i+1:]) + "Map"
}
}
}
case strings.HasPrefix(s, "["):
if i := strings.IndexByte(s, ']'); i > 0 {
return argName(s[i+1:]) + "Array"
}
case s == "interface {}" || s == "any":
return "Any"
case strings.HasPrefix(s, "struct"), strings.HasPrefix(s, "interface"), strings.HasPrefix(s, "func"),
strings.HasPrefix(s, "chan"):
return "Value"
}
head, rest := s, ""
if i := strings.IndexByte(s, '['); i >= 0 {
head, rest = s[:i], s[i:]
}
// A type's name follows the last dot: a package path may hold dots of its
// own (gopkg.in/yaml.v3.Node), a type name never does.
if i := strings.LastIndexByte(head, '.'); i >= 0 {
head = head[i+1:]
}
if head != "" {
head = strings.ToUpper(head[:1]) + head[1:]
}
return plainName(head + rest)
}
// declaring is the struct that declares the field at index within t: t itself
// for its own fields, the embedded struct for a promoted one.
func declaring(t reflect.Type, index []int) reflect.Type {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
for _, i := range index[:len(index)-1] {
t = t.Field(i).Type
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
}
return t
}
// Where a projection keeps the definitions its schemas refer to. An OpenAPI
// document has one place for them; a schema sent on its own carries its own.
const (
specDefs = "#/components/schemas/"
selfDefs = "#/$defs/"
)
// schemaRegistry is where a named struct type is described ONCE. Every use of it
// is a $ref into the registry — which is also the cycle guard: the entry is
// claimed BEFORE the type's fields are walked, so a type that contains itself
// finds the definition already in flight instead of recursing forever. Without
// it a self-referential In type overflows the stack while the document is built
// AND while the tools are listed, which is fatal — a stack overflow is not
// recoverable, so the service simply cannot start.
//
// prefix is the JSON Pointer that reaches defs. The projection owns where its
// definitions live; the derivation that fills them is the same one either way.
type schemaRegistry struct {
prefix string
defs map[string]any // name → definition
names map[reflect.Type]string // type → the name it is defined under
refs map[string]int // name → how many $refs point at it
// byID is the same map for a type that arrived as a description rather than
// as a reflect.Type — a manifest's type id → the name it is defined under.
// Two keys, one registry, because a document is assembled the same way
// whichever front end described the ops.
byID map[string]string
// origin is the app that DECLARED the op currently being described, when
// that op arrived through composition — empty for the host's own. It
// qualifies the names of the types that op reaches, so a composed document
// can carry two apps' Application without one overwriting the other. See
// nameFor. Set by buildOpenAPI, once per op; a type is qualified by
// whichever origin reached it FIRST and keeps that name everywhere, which
// is what makes one type one schema across the whole document.
origin string
}
func newSchemaRegistry(prefix string) *schemaRegistry {
return &schemaRegistry{
prefix: prefix,
defs: map[string]any{},
names: map[reflect.Type]string{},
refs: map[string]int{},
byID: map[string]string{},
}
}
// define describes t in the registry, if it is not there already, and returns
// the name it is described under. The entry is claimed before the fields are
// walked: whatever the walk reaches — including t itself — finds it.
func (r *schemaRegistry) define(t reflect.Type, fields map[string]string) string {
if name, ok := r.names[t]; ok {
return name
}
name := r.nameFor(t)
def := map[string]any{}
r.names[t] = name
r.defs[name] = def
structSchema(def, t, r, fields)
return name
}
// nameFor is the name t is described under: its Go name, qualified by the app
// that declared it when it came in through composition, and qualified by its package
// when a DIFFERENT type already holds the name it would take. Two packages may
// both call a type Config, and a registry that let the second overwrite the
// first would publish one type's fields under the other's name — with both ops'
// $refs pointing at whichever arrived last.
//
// The origin qualification is UNCONDITIONAL, not on collision. Qualifying only
// on collision makes a published type name a function of who else is in the
// room: add a schema to one app and another app's type silently renames, which
// renames a method's argument type in every generated SDK. One rule, one extra
// input — not a second naming scheme.
func (r *schemaRegistry) nameFor(t reflect.Type) string {
// Qualifiers, outermost first: the app that DECLARED the type — always,
// when it came in through composition — then its package, only when a different type
// already holds the name, then an ordinal, only when even that collides.
qual := ""
if r.origin != "" {
qual = r.origin + "."
}
base := qual + componentName(t)
if _, taken := r.defs[base]; !taken {
return base
}
if p := t.PkgPath(); p != "" {
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + componentName(t)
}
for name, n := base, 2; ; n++ {
if _, taken := r.defs[name]; !taken {
return name
}
name = base + strconv.Itoa(n)
}
}
// ref points at a definition. The count is what lets a standalone schema tell a
// definition something still refers to from one only its own root named.
func (r *schemaRegistry) ref(name string) map[string]any {
r.refs[name]++
return map[string]any{"$ref": r.prefix + name}
}
// schemaOf builds the JSON Schema for t. A named struct is DEFINED in reg and
// referred to by $ref, so one type is described once however many ops reach it;
// everything else is inlined, having no name to be referred to by. A field's doc
// comment becomes its description, keyed "Type.field" because an In and an Out
// can both have a "limit" and they are not the same thing.
//
// reg may be nil, which describes a struct by its shape without expanding it: a
// caller that only needs to know WHAT a value is — the CLI asking a field's flag
// kind — has nowhere to put a definition and no use for one.
func schemaOf(t reflect.Type, reg *schemaRegistry, fields map[string]string) map[string]any {
if t == nil {
return map[string]any{"type": "object"}
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
// A type that states its own wire form — MarshalJSON — is not described by
// what it is made of, and this is read FIRST because the rule is about the
// MARSHALER, not about being a struct. json.RawMessage is the case that
// mattered: a []byte whose MarshalJSON emits raw JSON. Below the slice rule
// it was published as an array of integers; under a struct-only check it
// would still be. A relay whose answer is frequently an array or null was
// asserted to be an object in openapi.yaml and in every SDK built from it.
if isMarshaler(t) {
// A type that writes its own JSON may also STATE its own shape. That is
// the same fact twice — the bytes and the schema both belong to the
// type — so it is declared once, next to MarshalJSON, rather than
// guessed here or listed in a table this package would have to keep.
if s := declaredSchema(t); s != nil {
return s
}
if t == timeType {
// The one marshaler whose output shape is DOCUMENTED: RFC 3339,
// which OpenAPI spells `format: date-time`. Its fields are all
// unexported, so describing it by them published `{}` properties for
// every timestamp in the fleet — an object where a string goes.
return map[string]any{"type": "string", "format": "date-time"}
}
// Otherwise: any JSON. Unconstrained is not undocumented — the field's
// prose still lands on it — and it is the only true thing to say.
return map[string]any{}
}
switch t.Kind() {
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer", "format": numberFormat(t.Kind())}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number", "format": numberFormat(t.Kind())}
case reflect.Slice, reflect.Array:
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
// encoding/json writes a byte SLICE as a base64 string, not as an
// array of numbers — and a byte ARRAY as the numbers, which is why
// this is not both kinds. A named []byte that means something else
// says so with MarshalJSON, and is caught above.
return map[string]any{"type": "string", "contentEncoding": "base64"}
}
return map[string]any{
"type": "array",
"items": schemaOf(t.Elem(), reg, fields),
}
case reflect.Map:
return map[string]any{
"type": "object",
"additionalProperties": schemaOf(t.Elem(), reg, fields),
}
case reflect.Interface:
// `any` holds whatever JSON arrives — a token string, an array, a number.
// Saying "object" made every example that carried a string there invalid
// against its own schema, and taught every generated client the wrong type.
return map[string]any{}
case reflect.Struct:
if reg == nil {
return map[string]any{"type": "object"}
}
if typeName(t) == "" {
// Anonymous: nothing to name a definition after — and nothing that
// can name it, so Go cannot spell a recursive one either.
out := map[string]any{}
structSchema(out, t, reg, fields)
return out
}
return reg.ref(reg.define(t, fields))
}
return map[string]any{"type": "object"}
}
// numberFormat names a number's WIDTH and SIGN, which "integer" alone does not.
//
// It matters because the document is what a client is generated from, and one
// of those clients speaks ZAP, where a field IS an offset and a width. A
// generated type that reads uint32 as int64 does not merely mis-print a value:
// it takes eight bytes where the service laid four, and every field after it is
// read from the wrong place. JSON survives the ambiguity because a number is
// self-delimiting; a fixed layout does not.
//
// The names are OpenAPI's own for the two it registers (int32, int64) and the
// Go spelling for the rest, which is what every generator already reads. A
// format is an annotation, so a consumer that ignores it sees exactly the
// document it saw before.
func numberFormat(k reflect.Kind) string {
switch k {
case reflect.Int, reflect.Int64:
return "int64"
case reflect.Int8:
return "int8"
case reflect.Int16:
return "int16"
case reflect.Int32:
return "int32"
case reflect.Uint, reflect.Uint64:
return "uint64"
case reflect.Uint8:
return "uint8"
case reflect.Uint16:
return "uint16"
case reflect.Uint32:
return "uint32"
case reflect.Float32:
return "float"
}
return "double"
}
// structSchema fills in one struct's object schema. It is separate from schemaOf
// because a definition is claimed in the registry before it is filled — that
// claim is the cycle guard, and it needs the map to exist first.
func structSchema(into map[string]any, t reflect.Type, reg *schemaRegistry, fields map[string]string) {
props := map[string]any{}
var required []string
for _, f := range wireFields(t) {
name := jsonFieldName(f)
if name == "-" || cookieFieldName(f) != "" {
continue // exists, but the body does not carry it: a cookie rides only as one.
}
fs := schemaOf(f.Type, reg, fields)
if d := fields[typeName(t)+"."+name]; d != "" {
fs["description"] = d
} else if d := fields[typeName(declaring(t, f.Index))+"."+name]; d != "" {
fs["description"] = d
}
props[name] = fs
if tag := f.Tag.Get("validate"); strings.Contains(tag, "required") {
required = append(required, name)
}
}
into["type"] = "object"
into["properties"] = props
if len(required) > 0 {
into["required"] = required
}
}
// rootSchemaOf is one type's schema as a STANDALONE document: the type itself
// inlined — an MCP tool's inputSchema has to BE the object, not a pointer at one
// — with every other named type it reaches carried alongside in $defs, so the
// value is self-contained wherever it is sent. What it describes is the same
// definition the OpenAPI document carries, which is the point: one type, one
// schema, whichever projection you read it from.
func rootSchemaOf(t reflect.Type, fields map[string]string) map[string]any {
reg := newSchemaRegistry(selfDefs)
root := schemaOf(t, reg, fields)
ref, isRef := root["$ref"].(string)
if !isRef {
return root // already inline: a scalar, a slice, an anonymous struct
}
// The root IS a definition. Inline a copy of it, and leave the entry behind
// only if something OTHER than the copy still points at it — which is
// exactly what a self-referential type does.
name := strings.TrimPrefix(ref, reg.prefix)
root = maps.Clone(reg.defs[name].(map[string]any))
if reg.refs[name] == 1 {
delete(reg.defs, name)
}
if len(reg.defs) > 0 {
root["$defs"] = reg.defs
}
return root
}
// statusText describes one response. The two defaults keep the words they have
// always had, so a document that never declared a status does not churn; anything
// declared gets the standard reason phrase, lowercased to match them.
func statusText(code int) string {
switch code {
case 200:
return "ok"
case 204:
return "no content"
}
if s := http.StatusText(code); s != "" {
return strings.ToLower(s)
}
return "ok"
}
// exampleFields splits an op's example object into its fields, so a parameter
// can carry the value the example gave it. A malformed or absent example yields
// nothing rather than failing the document — the schema is still true.
func exampleFields(ex json.RawMessage) map[string]json.RawMessage {
if len(ex) == 0 {
return nil
}
var m map[string]json.RawMessage
if json.Unmarshal(ex, &m) != nil {
return nil
}
return m
}
// docFields is the field map when the generator ran, nil otherwise.
func docFields(has bool, d Doc) map[string]string {
if !has {
return nil
}
return d.Fields
}
// firstSentence is the summary when none was given explicitly: a doc comment's
// opening sentence is already written to be one, which is why Go documents that
// convention in the first place.
//
// A summary is ONE SENTENCE ON ONE LINE. Neither followed from reading the source
// text verbatim up to ". ":
//
// - a first sentence that WRAPS in Go source put its line break into the
// OpenAPI summary, the CLI's one-line listing and the first docstring line of
// every generated SDK — 215 of 387 published summaries carried one;
// - a sentence ending at a line break has no ". " to stop at, so the scan ran
// on and swallowed the paragraphs after it, newlines and all.
//
// So the boundary is a period followed by ANY whitespace (or the end), and the
// result is whitespace-collapsed. The source's line breaks are facts about the
// source; a summary is a fact about the operation.
func firstSentence(s string) string {
if i := sentenceEnd(s); i >= 0 {
s = s[:i+1]
} else if i := strings.IndexByte(s, '\n'); i >= 0 {
// No sentence at all: the first line, as it has always been.
s = s[:i]
}
return strings.Join(strings.Fields(s), " ")
}
// sentenceEnd is the index of the period that ends the first sentence, or -1.
// "v1.2" and "hanzo.ai" are not sentence ends, because a sentence end is
// followed by space; "e.g. x" reads as one, which is Go's own convention for
// where a doc comment's summary stops and the reason to write "for example".
func sentenceEnd(s string) int {
for i := strings.IndexByte(s, '.'); i >= 0; {
if i+1 == len(s) || s[i+1] == ' ' || s[i+1] == '\n' || s[i+1] == '\t' || s[i+1] == '\r' {
return i
}
next := strings.IndexByte(s[i+1:], '.')
if next < 0 {
return -1
}
i += 1 + next
}
return -1
}
// jsonFieldName is the reflect view of the wire-name rule; cmd/zipdoc reads the
// AST view of the same rule, from the same function, so a field's doc lands
// under the name the decoder actually uses.
func jsonFieldName(f reflect.StructField) string {
return jsontag.Name(f.Name, f.Tag.Get("json"))
}
// urlFieldName is the name a field is carried under in the URL — `url:` when the
// field names one, else the name the body uses. "-" opts out, exactly as it does
// for `json:`.
//
// The two tags are the two halves of a request, and they are separate because a
// route may genuinely mean two different things by one word: PUT
// /v1/workers/scripts/:script takes the worker's NAME in the path and its SOURCE
// in the body, both spelled "script". With one name for both, bindURL bound the
// path last — it is the addressing authority, which is right — and deployed a
// worker whose code was its own name. So the route could not be typed at all.
//
// Name string `json:"-" url:"script"` // the URL's, and only the URL's
// Script string `json:"script" url:"-"` // the body's, and only the body's
//
// headerFieldName is the header a field reads, or "" when it reads none.
//
// A `header:"X-Tenant"` tag is what makes a REQUEST FACT part of the op's
// contract instead of something a middleware smuggles through a context slot.
// Declared here, it appears as a header parameter in the document, a flag on the
// command, and a property in the MCP tool schema — which is the whole test a
// replacement for that middleware has to pass: a fact no projection can see is
// not a fact the API has.
func headerFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("header"); ok {
return jsontag.Name(f.Name, tag)
}
return ""
}
func urlFieldName(f reflect.StructField) string {
if tag, ok := f.Tag.Lookup("url"); ok {
return jsontag.Name(f.Name, tag)
}
// The body taken as sent and a cookie each ride where they are declared,
// and a URL that could also carry one would be a second way to say it —
// for a cookie, a way for a link to set a session.
if f.Type == bodyType || cookieFieldName(f) != "" {
return "-"
}
return jsonFieldName(f)
}
// wireFields is the struct's fields AS THE DECODER SEES THEM: its own, plus the
// promoted fields of every embedded struct, in the order encoding/json resolves
// them. index is the path to the field through those embeddings, so a caller can
// reach the value with reflect.Value.FieldByIndex.
//
// It exists because four things asked "what fields does this type carry on the
// wire" and each answered with its own loop over NumField — which is not that
// question. encoding/json PROMOTES an embedded struct's fields to the outer
// object, and an embedded type is very often unexported (a shared patch body
// reused by two routes), so every one of those loops skipped it on IsExported and
// silently dropped every field it carried: the schema published an object with
// only the outer fields, the CLI offered no flag for them, the document declared
// no request body at all when the outer fields were all path params, and bindURL
// would not bind one from the URL. The wire took them the whole time.
//
// One function, so the promotion rule lives once. It answers STRUCTURE only:
// which fields exist, the outer type's and the promoted ones together. WHICH HALF
// of the request carries a field is a different question, answered by `json:` and
// `url:`, and each caller asks it — because a field can be URL-only
// (`json:"-" url:"script"`) or body-only (`json:"script" url:"-"`), and a filter
// here would silently drop one of the two halves.
//
// An embedded POINTER is skipped rather than followed: the decoder allocates it on
// demand, and a nil one has no fields to promote — a projection must not describe
// what binding cannot reach.
func wireFields(t reflect.Type) []reflect.StructField {
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return nil
}
var out []reflect.StructField
var walk func(reflect.Type, []int)
seen := map[reflect.Type]bool{}
walk = func(t reflect.Type, index []int) {
if seen[t] {
return // a struct embedding its own type: promote it once.
}
seen[t] = true
defer delete(seen, t)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
at := append(append([]int{}, index...), i)
if f.Anonymous && f.Type.Kind() == reflect.Struct {
// A tagged embedded struct is a NAMED object, not a promotion —
// that is encoding/json's rule, so it is this one's too.
if _, tagged := f.Tag.Lookup("json"); !tagged {
walk(f.Type, at)
continue
}
}
if !f.IsExported() {
continue
}
f.Index = at
out = append(out, f)
}
}
walk(t, nil)
return out
}
// jsonMarshaler and timeType are the two types schemaOf consults about a value's
// wire form: the interface that says "not my fields", and the one implementation
// of it whose output shape is documented.
var (
jsonMarshaler = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
timeType = reflect.TypeOf(time.Time{})
)
// isMarshaler reports whether t writes its own JSON. The pointer is checked too
// because that is where MarshalJSON is usually declared, and encoding/json finds
// it there for any addressable value.
func isMarshaler(t reflect.Type) bool {
return t.Implements(jsonMarshaler) || reflect.PointerTo(t).Implements(jsonMarshaler)
}
// SchemaDescriber is implemented by a type that writes its own JSON and can say
// what that JSON looks like. Without it a marshaler is published as `{}`: true,
// because the fields are not the wire form, but useless to anything generating
// a client — every such field arrives as an untyped value.
//
// The shape is a JSON Schema object, the same vocabulary the rest of the
// document speaks. A numeric carried as a quoted decimal, which is the usual
// reason a type marshals itself, says so directly:
//
// func (Uint64) JSONSchema() map[string]any {
// return map[string]any{"type": "string", "pattern": "^[0-9]+$"}
// }
//
// Implement it on the value, not the pointer, so a field of either form is
// described. Returning nil means "no shape to state" and falls back to `{}`.
type SchemaDescriber interface {
JSONSchema() map[string]any
}
var schemaDescriber = reflect.TypeOf((*SchemaDescriber)(nil)).Elem()
// declaredSchema asks a type for its own wire shape, or nil if it does not
// state one. A fresh copy is returned so a caller cannot edit the type's
// answer for everybody else — the schemas are assembled into one document by
// mutation, and a shared map would leak one field's constraints into another.
func declaredSchema(t reflect.Type) map[string]any {
var d SchemaDescriber
switch {
case t.Implements(schemaDescriber):
d, _ = reflect.Zero(t).Interface().(SchemaDescriber)
case reflect.PointerTo(t).Implements(schemaDescriber):
d, _ = reflect.New(t).Interface().(SchemaDescriber)
default:
return nil
}
if d == nil {
return nil
}
got := d.JSONSchema()
if got == nil {
return nil
}
out := make(map[string]any, len(got))
for k, v := range got {
out[k] = v
}
return out
}
// swaggerHTML is the minimal Swagger UI shell. Loads the UI from a CDN
// and points it at /.well-known/openapi.json. About 30 lines — no bundled
// JS in the binary.
const swaggerHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>API Docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/.well-known/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>
`
// OpenAPISpec returns the OpenAPI 3.1 document for every typed op registered on
// this app — the SAME value served at /.well-known/openapi.json.
//
// It is exported so a service can render its published contract from the routes it
// actually registers, in a build step rather than from a running server. A spec
// generated any other way is a second source of truth, and the whole point of
// deriving it here is that there is only one.
func (a *App) OpenAPISpec() map[string]any { return a.buildOpenAPI() }
// headerField is one declared header parameter: the header it reads, the field
// it lands on, and the shape it carries.
type headerField struct {
header string
field string
required bool
schema map[string]any
}
// headerFields is every `header:`-tagged field of an input type. It walks the
// same wireFields the decoder does, so a promoted field of an embedded struct
// declares its header exactly as an own field would — the document and the
// binder cannot disagree about which fields exist.
func headerFields(t reflect.Type) []headerField {
if t == nil {
return nil
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
var out []headerField
for _, f := range wireFields(t) {
name := headerFieldName(f)
if name == "" {
continue
}
out = append(out, headerField{
header: name,
field: jsonFieldName(f),
required: strings.Contains(f.Tag.Get("validate"), "required"),
schema: schemaOf(f.Type, nil, nil),
})
}
return out
}