mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
The hook answered allow-or-error, so a held operation had nowhere to go
but a refusal. It now returns Decision{Effect, Clause, Reason} decided
ONCE at the run() seam both op.invoke and op.direct funnel through, so
every projection inherits the same answer: REST renders 403 and 202, MCP
and the CLI render the same three outcomes, and OpenAPI publishes the
202 beside the op so a generated SDK reads Result<T> = Done | Pending.
Also binds the plugin socket in its own short base rather than under
spec.Dir. An AF_UNIX path is capped near 104 bytes; a test-named temp
dir plus the .sock.http suffix crossed it, the child exited before
listening, and the host served 503. The binary still extracts under
spec.Dir — only the socket moved.
1103 lines
41 KiB
Go
1103 lines
41 KiB
Go
package zip
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"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 {
|
|
cfg := a.cfg.OpenAPI
|
|
if cfg.Title == "" {
|
|
cfg.Title = a.cfg.AppName
|
|
}
|
|
if cfg.Title == "" {
|
|
cfg.Title = "zip API"
|
|
}
|
|
if cfg.Version == "" {
|
|
cfg.Version = "0.0.0"
|
|
}
|
|
|
|
paths := map[string]map[string]any{}
|
|
// One registry for the whole document: a type reached by two ops is one
|
|
// definition in components.schemas that both point at.
|
|
reg := newSchemaRegistry(specDefs)
|
|
|
|
// Sort ops by path,method for deterministic output.
|
|
ops := append([]*registeredOp{}, a.Registry()...)
|
|
sort.Slice(ops, func(i, j int) bool {
|
|
if ops[i].Path != ops[j].Path {
|
|
return ops[i].Path < ops[j].Path
|
|
}
|
|
return ops[i].Method < ops[j].Method
|
|
})
|
|
|
|
for _, op := range ops {
|
|
// Whose types these are. Empty for this app's own ops, so a document
|
|
// with nothing grafted into it is byte-identical to the one before
|
|
// Graft existed.
|
|
reg.origin = op.Origin
|
|
|
|
path := op.Path
|
|
// OpenAPI uses {name} for path params; fiber uses :name. Translate.
|
|
path = strings.ReplaceAll(path, "/:", "/{")
|
|
if strings.Contains(path, "{") && !strings.Contains(path, "}") {
|
|
// Was ":name" -> "{name" needs the closing brace.
|
|
path = closeColonParams(op.Path)
|
|
}
|
|
|
|
if _, ok := paths[path]; !ok {
|
|
paths[path] = map[string]any{}
|
|
}
|
|
// opName is the ONE place the id rule lives, shared with the MCP tool
|
|
// list, the op-call plane and the Authorizer's Op — so an operation is
|
|
// addressed by the same token whichever projection you came through.
|
|
opObj := map[string]any{
|
|
"operationId": opName(op),
|
|
"summary": op.Summary,
|
|
}
|
|
// Prose and examples extracted from the source by cmd/zipdoc. Absent
|
|
// when the generator has not run, which degrades to the schema-only
|
|
// spec rather than failing — a spec without descriptions is still a
|
|
// usable spec.
|
|
doc, hasDoc := docFor(op.Method, op.Path)
|
|
if hasDoc {
|
|
if doc.Description != "" {
|
|
opObj["description"] = doc.Description
|
|
}
|
|
if op.Summary == "" {
|
|
opObj["summary"] = firstSentence(doc.Description)
|
|
}
|
|
}
|
|
if len(op.Tags) > 0 {
|
|
opObj["tags"] = op.Tags
|
|
}
|
|
|
|
// Request body.
|
|
if hasRequestBody(op) {
|
|
media := map[string]any{"schema": schemaOf(op.InType, reg, docFields(hasDoc, doc))}
|
|
// An example is what makes a spec explorable — it is the difference
|
|
// between a reference someone reads and one they can press "try it"
|
|
// on.
|
|
if hasDoc && len(doc.Example) > 0 {
|
|
media["example"] = json.RawMessage(doc.Example)
|
|
}
|
|
opObj["requestBody"] = map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{"application/json": media},
|
|
}
|
|
}
|
|
|
|
// Path parameters. OpenAPI requires every templated segment to be declared,
|
|
// so they are derived from the route pattern itself — the same string the
|
|
// router matches on, so the spec cannot describe a parameter the route does
|
|
// not have (or omit one it does).
|
|
// A parameter's prose lives with the field it binds to, under the same
|
|
// "<Type>.<field>" key the in-process CLI reads. Looking it up here
|
|
// rather than restating it is what keeps a generated client and a
|
|
// linked-in one describing the same argument the same way — the
|
|
// alternative is a spec that silently has nothing to say about an
|
|
// argument the registry documents fine.
|
|
fields := docFields(hasDoc, doc)
|
|
inName := typeName(op.InType)
|
|
// A parameter's example is that field's value in the op's OWN example —
|
|
// the one the doc comment already wrote, split across the parameters
|
|
// that carry it. A bodyless op has no requestBody for the example to
|
|
// live in, and an example that only survives for methods with a body is
|
|
// an example missing from every GET and DELETE in the document.
|
|
example := exampleFields(doc.Example)
|
|
describe := func(decl map[string]any, field string) map[string]any {
|
|
if help := fields[inName+"."+field]; help != "" {
|
|
decl["description"] = help
|
|
}
|
|
if v, ok := example[field]; ok {
|
|
decl["example"] = v
|
|
}
|
|
return decl
|
|
}
|
|
|
|
// A parameter's TYPE is the type of the field it binds to, wherever the
|
|
// URL carried it from. bindURL is one binder over one set of fields, so
|
|
// the document reads that one set too — declaring every path param a
|
|
// string while consulting the input for query params described the same
|
|
// value two different ways depending on which half of the URL it rode in.
|
|
url := urlFields(op.InType)
|
|
params := colonParams(op.Path)
|
|
decls := make([]any, 0, len(params))
|
|
named := make(map[string]bool, len(params))
|
|
for _, p := range params {
|
|
named[strings.ToLower(p)] = true
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": p, "in": "path", "required": true,
|
|
"schema": url.paramSchema(p),
|
|
}, p))
|
|
}
|
|
// Header parameters. A field carrying `header:"X-Foo"` is a REQUEST FACT
|
|
// the op declared, so the document names it — that is what makes reading
|
|
// a header part of the contract instead of something a middleware does
|
|
// off to the side where no projection can see it. Declared for every
|
|
// method, because a header rides a POST as readily as a GET, and excluded
|
|
// from the query list below so one field is never described twice.
|
|
hdr := headerFields(op.InType)
|
|
for _, h := range hdr {
|
|
named[strings.ToLower(h.field)] = true
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": h.header, "in": "header", "required": h.required,
|
|
"schema": h.schema,
|
|
}, h.field))
|
|
}
|
|
|
|
// Query parameters. A bodyless method binds its input from the URL
|
|
// (typed.go bindURL), so every In field that is NOT already a path
|
|
// segment is reachable as `?field=` — and the document has to say so, or
|
|
// it describes a route nobody can call correctly. Declared only where
|
|
// there is no requestBody, because that is exactly where the binder
|
|
// treats the URL as the whole input.
|
|
if !hasBody(op.Method) {
|
|
for _, f := range url {
|
|
if named[strings.ToLower(f.name)] {
|
|
continue
|
|
}
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": f.name, "in": "query", "required": f.required,
|
|
"schema": f.schema,
|
|
}, f.name))
|
|
}
|
|
}
|
|
if len(decls) > 0 {
|
|
opObj["parameters"] = decls
|
|
}
|
|
|
|
// The success response, keyed on the status the op DECLARED — the whole
|
|
// reason WithStatus is on the op rather than set per request: a 201 that
|
|
// only reached the wire would leave every generated client expecting a
|
|
// 200 the service never sends.
|
|
if op.OutType != nil && typeName(op.OutType) != "" {
|
|
respMedia := map[string]any{"schema": schemaOf(op.OutType, reg, docFields(hasDoc, doc))}
|
|
if hasDoc && len(doc.Response) > 0 {
|
|
respMedia["example"] = json.RawMessage(doc.Response)
|
|
}
|
|
resp := map[string]any{}
|
|
for _, code := range declaredStatuses(op, 200) {
|
|
entry := map[string]any{
|
|
"description": statusText(code),
|
|
"content": map[string]any{"application/json": respMedia},
|
|
}
|
|
if h := responseHeaderSchemas(op); h != nil {
|
|
entry["headers"] = h
|
|
}
|
|
resp[strconv.Itoa(code)] = entry
|
|
}
|
|
opObj["responses"] = resp
|
|
} else {
|
|
resp := map[string]any{}
|
|
for _, code := range declaredStatuses(op, 204) {
|
|
entry := map[string]any{"description": statusText(code)}
|
|
if h := responseHeaderSchemas(op); h != nil {
|
|
entry["headers"] = h
|
|
}
|
|
resp[strconv.Itoa(code)] = entry
|
|
}
|
|
opObj["responses"] = resp
|
|
}
|
|
|
|
// A gated service can park ANY op for approval, so the document publishes
|
|
// the pending body beside every op's success response: a generated SDK
|
|
// reads the pair as Result<T> = Done<T> | Approval and cannot mistake a
|
|
// parked op for a finished one. Declared only when a hook is installed —
|
|
// an ungated service never parks, so its document says nothing about it.
|
|
if a.authorizer != nil {
|
|
if resp, ok := opObj["responses"].(map[string]any); ok {
|
|
resp["202"] = map[string]any{
|
|
"description": "pending approval",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{"schema": schemaOf(heldType, reg, nil)},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
paths[path][strings.ToLower(op.Method)] = opObj
|
|
}
|
|
|
|
return map[string]any{
|
|
"openapi": "3.1.0",
|
|
"info": map[string]any{
|
|
"title": cfg.Title,
|
|
"description": cfg.Description,
|
|
"version": cfg.Version,
|
|
},
|
|
"paths": paths,
|
|
"components": map[string]any{
|
|
"schemas": reg.defs,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 colonParams(op.Path) {
|
|
named[strings.ToLower(p)] = 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 schema of the value, and whether the handler refuses to run without it.
|
|
type urlField struct {
|
|
name string
|
|
schema map[string]any
|
|
required bool
|
|
}
|
|
|
|
// urlFields lists the top-level scalar fields of an op's input — the exact set
|
|
// bindURL can fill, 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. Non-scalars are omitted because the binder cannot fill them either, so
|
|
// naming them would promise a parameter that silently does nothing.
|
|
func urlFields(t reflect.Type) urlFieldList {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t.Kind() != reflect.Struct {
|
|
return nil
|
|
}
|
|
var out urlFieldList
|
|
for _, f := range wireFields(t) {
|
|
name := urlFieldName(f)
|
|
if name == "-" {
|
|
continue // on the wire, but not in the URL.
|
|
}
|
|
ft := f.Type
|
|
for ft.Kind() == reflect.Pointer {
|
|
ft = ft.Elem()
|
|
}
|
|
switch ft.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:
|
|
out = append(out, urlField{
|
|
name: name,
|
|
schema: schemaOf(ft, nil, nil),
|
|
// 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"),
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type urlFieldList []urlField
|
|
|
|
// 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"}
|
|
}
|
|
|
|
// colonParams lists the ":name" params of a fiber route pattern, in order.
|
|
func colonParams(path string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(path, "/") {
|
|
if strings.HasPrefix(p, ":") && len(p) > 1 {
|
|
out = append(out, p[1:])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// closeColonParams is the document's spelling of a router path — see [Template],
|
|
// which is the same rule under its public name.
|
|
func closeColonParams(path string) string { return Template(path) }
|
|
|
|
func typeName(t reflect.Type) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t.Name() == "" {
|
|
return ""
|
|
}
|
|
return t.Name()
|
|
}
|
|
|
|
// 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
|
|
|
|
// origin is the app that DECLARED the op currently being described, when
|
|
// that op arrived through Graft — 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{},
|
|
}
|
|
}
|
|
|
|
// 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 on a graft, 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 on a graft — 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 + t.Name()
|
|
if _, taken := r.defs[base]; !taken {
|
|
return base
|
|
}
|
|
if p := t.PkgPath(); p != "" {
|
|
base = qual + p[strings.LastIndexByte(p, '/')+1:] + "." + t.Name()
|
|
}
|
|
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) {
|
|
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"}
|
|
case reflect.Float32, reflect.Float64:
|
|
return map[string]any{"type": "number"}
|
|
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.Struct:
|
|
if reg == nil {
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
if t.Name() == "" {
|
|
// 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"}
|
|
}
|
|
|
|
// 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 == "-" {
|
|
continue // exists, but the body does not carry it.
|
|
}
|
|
fs := schemaOf(f.Type, reg, fields)
|
|
if d := fields[t.Name()+"."+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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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() }
|
|
|
|
// primaryStatus is the code an op answers with when its output states nothing —
|
|
// the first it declared. The document keys the success response on it, and every
|
|
// OTHER declared status gets its own entry beside it (see multiStatus), because
|
|
// a code the service can send and the document omits is a code no generated
|
|
// client will handle.
|
|
// declaredStatuses is every success code an op may answer with — all of them,
|
|
// not just the first. An op that declares 200 and 201 can send either, so the
|
|
// document says both; publishing one would leave a generated client with no
|
|
// branch for the other, which is precisely the hole a per-request status slot
|
|
// left open.
|
|
func declaredStatuses(op *registeredOp, dflt int) []int {
|
|
if len(op.Statuses) == 0 {
|
|
return []int{dflt}
|
|
}
|
|
return op.Statuses
|
|
}
|
|
|
|
func primaryStatus(op *registeredOp) int {
|
|
if len(op.Statuses) == 0 {
|
|
return 0
|
|
}
|
|
return op.Statuses[0]
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// responseHeaderSchemas is the `headers` object of a response, from what the op
|
|
// declared with [WithResponseHeader].
|
|
//
|
|
// A response header a caller relies on — a cache directive, a payment challenge,
|
|
// a Set-Cookie — is part of the contract, so the document names it. Without this
|
|
// the header would be set on the wire and described nowhere, which is the same
|
|
// invisibility that made a context slot the wrong home for it.
|
|
func responseHeaderSchemas(op *registeredOp) map[string]any {
|
|
if len(op.ResponseHeaders) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(op.ResponseHeaders))
|
|
for _, name := range op.ResponseHeaders {
|
|
out[name] = map[string]any{
|
|
"description": "Set by " + op.Method + " " + op.Path + ".",
|
|
"schema": map[string]any{"type": "string"},
|
|
}
|
|
}
|
|
return out
|
|
}
|