cli: the registry is the CLI, and the doc comment is what describes it

One registry, three projections. A typed op already produced a REST route, an
OpenAPI path and an MCP tool; it now also produces a command. cli.go derives
`<service> <operation>` from the op's own identity, so adding a route can never
rename an existing command, and LocalInvoke runs it through the op's own invoke
seam — a fused binary needs no client because the command IS the handler call.
clispec.go builds the same commands from a served spec, which is what lets the
CLI drive a service it was not linked against.

cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist
at run time, so prose reaches the spec only via a build-time AST pass. Field
docs and examples are extracted from source and keyed "<Type>.<field>".

Two defects found by making the projections agree:

commandName treated "ends in a parameter" as the only way to address one thing,
so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a
collection, returning one deploy. The route already says which it is past its
parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural.

OpenAPI parameters carried no description while the registry had one, so the
same argument had help in-process and none from the spec. Both now read the
same key, and query parameters get it too.

internal/jsontag exists so the reflect view of the wire-name rule and the AST
view cannot drift; transport gains a dial-only https, because reaching a
published API is an ordinary Mount and terminating TLS stays the ingress's job.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 17:02:36 -07:00
co-authored by Hanzo Dev
parent a40770a6eb
commit bb518e2ba2
17 changed files with 2504 additions and 34 deletions
+3 -3
View File
@@ -42,9 +42,9 @@ import (
// zipTaxRoutes covers the routing shapes a wrapper tax should be constant
// across: a static route and a single-param route.
var zipTaxRoutes = []struct {
name string
route string // registration pattern
path string // concrete request path
name string
route string // registration pattern
path string // concrete request path
}{
{"static", "/v1/health", "/v1/health"},
{"param", "/v1/tracker/:id", "/v1/tracker/trk_abc123"},
+904
View File
@@ -0,0 +1,904 @@
package zip
import (
"context"
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
// CLI — the FOURTH projection. The same typed-op registry (a.ops) that produces
// the REST routes, the OpenAPI document and the MCP tools produces a command
// line: an op becomes `<service> <operation>`, its In fields become flags, its
// doc comment becomes the help, and its Example becomes the example invocation.
//
// Nothing about a command is written anywhere. Registering a typed route adds
// the command, its flags, its help and its example — with no edit to any CLI
// source, which is the only test that proves a CLI is derived rather than
// merely generated once and maintained by hand afterwards. A command therefore
// cannot drift from the API: there is no second place for it to drift in.
//
// Two axes, kept apart:
//
// - DERIVATION. App.Commands reads the registry in THIS process.
// CommandsFromSpec reads it over the wire, off the OpenAPI document the
// owning service derives from that same registry. Same registry, one hop.
// - EXECUTION. LocalInvoke runs the handler here; Remote.Invoke sends it to a
// running service.
//
// They compose freely, which is what makes one command tree serve both a fused
// binary and a control-plane client. A client CLI does not link the service
// graph at all: it asks the service what it can do, and a route added this
// morning is a command this afternoon with nothing rebuilt.
//
// It also pairs with Plugin.Lazy: a lazily loaded service starts on the first
// request that reaches its prefix, so running one command starts exactly the
// one service that command touches, and a host composing dozens of them pays
// for the one you used.
// Command is one operation as a command line: the value both the help text and
// the argument parser read. It is the CLI's whole surface — a derivation fills
// it in, the runner consumes it, and neither knows how the other got there.
type Command struct {
// Service is the first non-version segment of the path ("billing"), and
// Name is the operation token under it ("invoices-list").
Service string
Name string
// Summary is the one-line help; Description is the full prose. Both come
// from the handler's doc comment when cmd/zipdoc has run.
Summary string
Description string
// Method and Path are the operation's identity — the route pattern in
// fiber's ":name" form, whatever the derivation read it from.
Method string
Path string
// Args are the path parameters, in path order, as positional arguments. The
// URL is the addressing authority (see bindPath), so what addresses the
// resource is positional and what modifies the request is a flag.
Args []Arg
// Flags are the remaining In fields.
Flags []Flag
// Example is the request body from the doc comment, rendered by the help as
// a runnable command line.
Example json.RawMessage
// op is set only by App.Commands: it is what LocalInvoke runs. A
// spec-derived command has none and must be executed remotely.
op *registeredOp
}
// Arg is one path parameter as a positional argument.
type Arg struct {
Name string // the param name, "app"
Help string
}
// Flag is one In field as a flag.
type Flag struct {
Name string // the flag, kebab-cased and without the dashes: "organization-id"
Field string // the JSON field it sets: "organizationId"
Type string // string | integer | number | boolean | json
Help string
Required bool
}
// Commands projects every registered typed op into a command. This is the whole
// derivation: no registration, no list of commands, no per-endpoint code.
func (a *App) Commands() []Command {
cmds := make([]Command, 0, len(a.ops))
for _, op := range a.ops {
doc, has := docFor(op.Method, op.Path)
c := newCommand(op.Method, op.Path, op.Summary, doc, has)
c.op = op
c.Args, c.Flags = bindIn(op.InType, colonParams(op.Path), docFields(has, doc))
cmds = append(cmds, c)
}
sortCommands(cmds)
return cmds
}
// newCommand fills in everything that comes from the op's identity and its doc,
// which is the half both derivations share.
func newCommand(method, path, summary string, doc Doc, has bool) Command {
svc, name := commandName(method, path)
c := Command{Service: svc, Name: name, Summary: summary, Method: method, Path: path}
if has {
c.Description = doc.Description
c.Example = doc.Example
if c.Summary == "" {
c.Summary = firstSentence(doc.Description)
}
}
return c
}
// bindIn splits an In type into positional args and flags. A field whose name
// matches a path parameter is positional and is NOT also a flag: the URL
// addresses the resource, so offering a second way to set the same value would
// be two ways to say one thing (and bindPath would overrule one of them).
func bindIn(in reflect.Type, params []string, fieldDocs map[string]string) ([]Arg, []Flag) {
args := make([]Arg, 0, len(params))
for _, p := range params {
args = append(args, Arg{Name: p, Help: fieldDocs[typeName(in)+"."+p]})
}
if in == nil {
return args, nil
}
t := in
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return args, nil
}
var flags []Flag
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := jsonFieldName(f)
if name == "-" || isParam(params, name) {
continue
}
flags = append(flags, Flag{
Name: kebab(name),
Field: name,
Type: flagType(f.Type),
Help: fieldDocs[t.Name()+"."+name],
Required: strings.Contains(f.Tag.Get("validate"), "required"),
})
}
// The args' help lives under the field's own json name, which is what the
// extraction keyed it by; look it up now that the type is in hand.
for i, a := range args {
if a.Help == "" {
args[i].Help = fieldDocs[t.Name()+"."+a.Name]
}
}
return args, flags
}
func isParam(params []string, name string) bool {
for _, p := range params {
if strings.EqualFold(p, name) {
return true
}
}
return false
}
// flagType maps a Go type to the flag's value kind. Anything compound is JSON:
// a struct, map or slice has no unambiguous flat spelling, and inventing one
// would be a second encoding of a value JSON already encodes.
func flagType(t reflect.Type) string {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Bool:
return "boolean"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
}
return "json"
}
// commandName derives `<service> <operation>` from an op's identity — method and
// path, the same pair the router, the document and the tool surface key on.
//
// GET /v1/paas/apps → paas apps-list
// GET /v1/paas/apps/:app → paas apps-get <app>
// POST /v1/paas/apps/:app/deploy → paas apps-deploy <app>
// GET /v1/paas/apps/:app/deploy → paas apps-deploy-get <app>
// POST /v1/billing/charge → billing charge-create
// DELETE /v1/keys/:id → keys delete <id>
//
// The name is a function of that one op and nothing else. It cannot change
// because a sibling route was added — a rule that shortened names while they
// stayed unique would rename a command, and therefore break a script, every
// time an unrelated endpoint shipped.
//
// POST is the only method whose verb an action segment replaces, because POST
// on a path ending in a verb IS the REST spelling of "run this" — `apps-deploy`
// rather than `apps-deploy-create`. Every other method still appends its own
// verb, so no two ops on one path can collide.
func commandName(method, path string) (service, name string) {
segs := make([]string, 0, 8)
for _, s := range strings.Split(path, "/") {
if s != "" && !isVersion(s) {
segs = append(segs, s)
}
}
// The service is the first static segment; a leading parameter (rare) is
// addressing, not naming, and is picked up as an argument like any other.
svc := 0
for svc < len(segs) && strings.HasPrefix(segs[svc], ":") {
svc++
}
if svc == len(segs) {
return "root", methodVerb(method, len(segs) > 0)
}
service = segs[svc]
rest := segs[svc+1:]
first, last := -1, -1
for i, s := range rest {
if strings.HasPrefix(s, ":") {
if first < 0 {
first = i
}
last = i
}
}
var words []string
var tail []string
if first < 0 {
words = append(words, rest...)
} else {
words = append(words, rest[:first]...)
tail = rest[last+1:]
words = append(words, tail...)
}
if !(method == "POST" && len(tail) > 0) {
// One thing or many. A path ending in a parameter is always one. Past
// that the last word decides, because a route that continues after its
// parameter has already said which it is: /apps/:app/deploy addresses one
// deploy, /nodes/:node/jobs addresses many jobs.
item := last >= 0 && len(tail) == 0
if !item && len(words) > 0 {
item = !plural(words[len(words)-1])
}
words = append(words, methodVerb(method, item))
}
for i, w := range words {
words[i] = kebab(w)
}
return kebab(service), strings.Join(words, "-")
}
// plural is the collection test. English "ends in s" is crude, but it reads the
// same signal the route author already gave when they named the segment, so it
// tracks intent without a second place to keep in sync — and the endings that
// break it (-ss, -us, -is) are singular, which is the direction that matters:
// mistaking one thing for many is the error that produces a wrong command name.
func plural(w string) bool {
return strings.HasSuffix(w, "s") &&
!strings.HasSuffix(w, "ss") &&
!strings.HasSuffix(w, "us") &&
!strings.HasSuffix(w, "is")
}
// methodVerb is the verb a method contributes. item says the path addresses one
// thing rather than many, which is what separates "get one" from "list many".
func methodVerb(method string, item bool) string {
switch method {
case "GET":
if item {
return "get"
}
return "list"
case "POST":
return "create"
case "PUT", "PATCH":
return "update"
case "DELETE":
return "delete"
}
return strings.ToLower(method)
}
// isVersion reports whether a segment is an API version ("v1"), which names the
// contract rather than the resource and so is not part of any command's name.
func isVersion(s string) bool {
if len(s) < 2 || s[0] != 'v' {
return false
}
for _, r := range s[1:] {
if r < '0' || r > '9' {
return false
}
}
return true
}
// kebab renders a Go/JSON identifier as a flag or command word: organizationId
// → organization-id, ID → id, snake_case → snake-case.
func kebab(s string) string {
var b strings.Builder
b.Grow(len(s) + 4)
rs := []rune(s)
for i, r := range rs {
switch {
case r == '_' || r == ' ' || r == '.':
b.WriteByte('-')
continue
case r >= 'A' && r <= 'Z':
prevLower := i > 0 && (rs[i-1] >= 'a' && rs[i-1] <= 'z' || rs[i-1] >= '0' && rs[i-1] <= '9')
nextLower := i+1 < len(rs) && rs[i+1] >= 'a' && rs[i+1] <= 'z'
if i > 0 && (prevLower || nextLower) {
b.WriteByte('-')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return strings.Trim(b.String(), "-")
}
func sortCommands(cmds []Command) {
sort.Slice(cmds, func(i, j int) bool {
if cmds[i].Service != cmds[j].Service {
return cmds[i].Service < cmds[j].Service
}
return cmds[i].Name < cmds[j].Name
})
}
// ---------------------------------------------------------------------------
// Execution
// ---------------------------------------------------------------------------
// Invoker performs one parsed command. path holds the positional arguments by
// parameter name and body is the JSON built from the flags — exactly what a
// typed op's invoke seam takes, so a command runs through the same decode,
// validate and authorize path as a REST request or an MCP tool call.
type Invoker func(ctx context.Context, c Command, path map[string]string, body []byte) (any, error)
// LocalInvoke runs the handler in this process, through the op's own invoke
// seam. It is the whole reason a fused binary needs no client: the command IS
// the handler call.
func LocalInvoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
if c.op == nil || c.op.invoke == nil {
return nil, fmt.Errorf("%s %s is not registered in this process — give the CLI a Remote invoker", c.Service, c.Name)
}
// No query map: a command's arguments are named, so they bind as path
// values. A CLI has no URL for the "?a=b" half to have come from.
return c.op.invoke(ctx, body, nil, path)
}
// Remote executes a command against a running zip service, and reads that
// service's registry off the document it derives from it. Base is an address in
// the same form Mount takes — the scheme selects the transport, so a command
// runs over ZAP, HTTP or anything else RegisterTransport'd without the CLI
// knowing which.
type Remote struct {
Base string // "https://api.hanzo.ai", "http://:8080", a unix socket path…
Header map[string]string // sent on every request (Authorization, …)
}
// Spec fetches the OpenAPI document — the registry in wire form. Pair it with
// CommandsFromSpec to build a command tree for a service this binary does not
// link.
func (r Remote) Spec(ctx context.Context) ([]byte, error) {
return r.do(ctx, "GET", "/.well-known/openapi.json", nil)
}
// Invoke sends one command to the service. Its signature is Invoker's, so it
// drops into a CLI wherever LocalInvoke would.
func (r Remote) Invoke(ctx context.Context, c Command, path map[string]string, body []byte) (any, error) {
url := c.Path
for name, val := range path {
url = strings.ReplaceAll(url, ":"+name, urlEscape(val))
}
// A GET/HEAD carries no body; its non-path inputs belong in the query
// string, which is where the route's own decoder looks for them.
if (c.Method == "GET" || c.Method == "HEAD") && len(body) > 0 {
q, err := queryOf(body)
if err != nil {
return nil, err
}
if q != "" {
url += "?" + q
}
body = nil
}
out, err := r.do(ctx, c.Method, url, body)
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
return json.RawMessage(out), nil
}
// do performs one request over the transport Base names. ctx is honoured up to
// the point the request is handed to the transport, which owns its own
// deadlines from there.
func (r Remote) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
scheme, host, t, err := transportFor(r.Base)
if err != nil {
return nil, err
}
if t.Dial == nil {
return nil, fmt.Errorf("zip: transport %q cannot dial (serve-only)", scheme)
}
req, resp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(path)
req.SetHost(host)
req.Header.SetMethod(method)
if scheme == "https" {
req.URI().SetScheme("https")
}
if len(body) > 0 {
req.SetBody(body)
req.Header.SetContentType("application/json")
}
for k, v := range r.Header {
req.Header.Set(k, v)
}
if err := t.Dial(host).Do(req, resp); err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
out := append([]byte(nil), resp.Body()...)
if code := resp.StatusCode(); code >= 400 {
return nil, fmt.Errorf("%s %s: %d %s", method, path, code, strings.TrimSpace(string(out)))
}
return out, nil
}
// queryOf renders a flat JSON object as a query string, sorted so a command
// line always produces the same URL.
func queryOf(body []byte) (string, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return "", err
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if b.Len() > 0 {
b.WriteByte('&')
}
v := string(m[k])
if s, err := strconv.Unquote(v); err == nil {
v = s
}
b.WriteString(urlEscape(k))
b.WriteByte('=')
b.WriteString(urlEscape(v))
}
return b.String(), nil
}
// urlEscape percent-encodes everything outside the unreserved set. Small enough
// to not be worth a net/url import in a package this hot.
func urlEscape(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0xf])
}
return b.String()
}
// ---------------------------------------------------------------------------
// The runner
// ---------------------------------------------------------------------------
// CLI runs a derived command tree. Name is what the usage lines call the
// binary; Invoke defaults to LocalInvoke and Out to os.Stdout's stand-in the
// caller passes.
type CLI struct {
Name string
Commands []Command
Invoke Invoker
Out io.Writer
}
// CLI returns the command line for everything registered on this app, executed
// in-process. `app.CLI().Run(ctx, os.Args[1:])` is a complete CLI for a service.
func (a *App) CLI() *CLI {
name := a.cfg.AppName
if name == "" {
name = "zip"
}
return &CLI{Name: name, Commands: a.Commands(), Invoke: LocalInvoke}
}
// Run executes one command line. It is the only entry point: help, dispatch and
// errors all come out of here so there is one place a CLI behaves.
func (c *CLI) Run(ctx context.Context, args []string) error {
out := c.Out
if out == nil {
return fmt.Errorf("zip: CLI.Out is nil")
}
if c.Invoke == nil {
c.Invoke = LocalInvoke
}
args = trimHelp(args)
if len(args) == 0 {
c.usage(out)
return nil
}
if len(args) == 1 {
if !c.hasService(args[0]) {
c.usage(out)
return fmt.Errorf("unknown service %q", args[0])
}
c.serviceUsage(out, args[0])
return nil
}
matches := c.find(args[0], args[1])
switch len(matches) {
case 0:
if c.hasService(args[0]) {
c.serviceUsage(out, args[0])
return fmt.Errorf("unknown operation %q for %s", args[1], args[0])
}
c.usage(out)
return fmt.Errorf("unknown service %q", args[0])
case 1:
default:
// Two ops that name themselves identically. Never resolved by picking
// one: silently running the wrong route is worse than saying so.
var paths []string
for _, m := range matches {
paths = append(paths, m.Method+" "+m.Path)
}
return fmt.Errorf("%s %s is ambiguous: %s", args[0], args[1], strings.Join(paths, ", "))
}
cmd := matches[0]
rest := args[2:]
if wantsHelp(rest) {
cmd.help(out, c.Name)
return nil
}
path, body, err := cmd.parse(rest)
if err != nil {
cmd.help(out, c.Name)
return err
}
res, err := c.Invoke(ctx, cmd, path, body)
if err != nil {
return err
}
return writeResult(out, res)
}
// parse turns the remaining argv into the two values an op's invoke seam takes:
// the path parameters and the JSON body. Only flags that were actually given
// appear in the body — an absent flag must not become a zero that overwrites a
// server-side default.
func (c Command) parse(args []string) (map[string]string, []byte, error) {
byName := make(map[string]Flag, len(c.Flags))
for _, f := range c.Flags {
byName[f.Name] = f
}
fields := map[string]json.RawMessage{}
var positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if !strings.HasPrefix(a, "--") {
positional = append(positional, a)
continue
}
name, val, hasVal := strings.Cut(strings.TrimPrefix(a, "--"), "=")
f, ok := byName[name]
if !ok {
return nil, nil, fmt.Errorf("unknown flag --%s for %s %s", name, c.Service, c.Name)
}
if !hasVal {
// A boolean stands alone; anything else takes the next argument.
if f.Type == "boolean" {
val = "true"
} else {
if i+1 >= len(args) {
return nil, nil, fmt.Errorf("--%s needs a value", name)
}
i++
val = args[i]
}
}
raw, err := encodeFlag(f, val)
if err != nil {
return nil, nil, err
}
fields[f.Field] = raw
}
if len(positional) != len(c.Args) {
return nil, nil, fmt.Errorf("%s %s takes %d argument(s): %s",
c.Service, c.Name, len(c.Args), argNames(c.Args))
}
path := make(map[string]string, len(c.Args))
for i, a := range c.Args {
path[a.Name] = positional[i]
}
for _, f := range c.Flags {
if f.Required {
if _, ok := fields[f.Field]; !ok {
return nil, nil, fmt.Errorf("--%s is required", f.Name)
}
}
}
if len(fields) == 0 {
return path, nil, nil
}
body, err := json.Marshal(fields)
return path, body, err
}
// encodeFlag turns one flag value into the JSON it stands for, refusing a value
// its type cannot hold rather than sending something the handler will reject
// with a worse message.
func encodeFlag(f Flag, val string) (json.RawMessage, error) {
switch f.Type {
case "string":
b, err := json.Marshal(val)
return b, err
case "integer":
if _, err := strconv.ParseInt(val, 10, 64); err != nil {
return nil, fmt.Errorf("--%s wants an integer, got %q", f.Name, val)
}
return json.RawMessage(val), nil
case "number":
if _, err := strconv.ParseFloat(val, 64); err != nil {
return nil, fmt.Errorf("--%s wants a number, got %q", f.Name, val)
}
return json.RawMessage(val), nil
case "boolean":
b, err := strconv.ParseBool(val)
if err != nil {
return nil, fmt.Errorf("--%s wants true or false, got %q", f.Name, val)
}
return json.RawMessage(strconv.FormatBool(b)), nil
default:
if !json.Valid([]byte(val)) {
return nil, fmt.Errorf("--%s wants JSON, got %q", f.Name, val)
}
return json.RawMessage(val), nil
}
}
func writeResult(w io.Writer, res any) error {
if res == nil {
return nil
}
b, err := json.MarshalIndent(res, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func (c *CLI) find(service, name string) []Command {
var out []Command
for _, cmd := range c.Commands {
if cmd.Service == service && cmd.Name == name {
out = append(out, cmd)
}
}
return out
}
func (c *CLI) hasService(service string) bool {
for _, cmd := range c.Commands {
if cmd.Service == service {
return true
}
}
return false
}
// trimHelp drops a leading help token so `cli help billing` and `cli billing`
// reach the same place.
func trimHelp(args []string) []string {
if len(args) > 0 && (args[0] == "help" || args[0] == "-h" || args[0] == "--help") {
return args[1:]
}
return args
}
func wantsHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
return true
}
}
return false
}
func argNames(args []Arg) string {
if len(args) == 0 {
return "(none)"
}
parts := make([]string, len(args))
for i, a := range args {
parts[i] = "<" + a.Name + ">"
}
return strings.Join(parts, " ")
}
// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------
func (c *CLI) usage(w io.Writer) {
fmt.Fprintf(w, "%s — %d operations, derived from the service registry\n\n", c.Name, len(c.Commands))
fmt.Fprintf(w, "Usage:\n %s <service> <operation> [args] [flags]\n\n", c.Name)
if len(c.Commands) == 0 {
fmt.Fprintf(w, "No typed operations are registered, so there is nothing to derive.\n")
return
}
counts := map[string]int{}
var services []string
for _, cmd := range c.Commands {
if counts[cmd.Service] == 0 {
services = append(services, cmd.Service)
}
counts[cmd.Service]++
}
sort.Strings(services)
fmt.Fprintf(w, "Services:\n")
width := 0
for _, s := range services {
width = max(width, len(s))
}
for _, s := range services {
fmt.Fprintf(w, " %-*s %d operations\n", width, s, counts[s])
}
fmt.Fprintf(w, "\nRun `%s <service>` to list its operations.\n", c.Name)
}
func (c *CLI) serviceUsage(w io.Writer, service string) {
fmt.Fprintf(w, "%s %s\n\nUsage:\n %s %s <operation> [args] [flags]\n\nOperations:\n",
c.Name, service, c.Name, service)
width := 0
for _, cmd := range c.Commands {
if cmd.Service == service {
width = max(width, len(cmd.Name)+len(argNames(cmd.Args)))
}
}
for _, cmd := range c.Commands {
if cmd.Service != service {
continue
}
use := cmd.Name
if len(cmd.Args) > 0 {
use += " " + argNames(cmd.Args)
}
fmt.Fprintf(w, " %-*s %s\n", width+1, use, cmd.Summary)
}
}
// help renders one command: what it does, how to call it, every flag, and an
// example built from the doc comment's own request body — so the example a
// reader copies is the example the spec ships, not a second one to keep true.
func (c Command) help(w io.Writer, bin string) {
fmt.Fprintf(w, "%s %s %s", bin, c.Service, c.Name)
if len(c.Args) > 0 {
fmt.Fprintf(w, " %s", argNames(c.Args))
}
if len(c.Flags) > 0 {
fmt.Fprintf(w, " [flags]")
}
fmt.Fprintf(w, "\n\n")
if c.Description != "" {
fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(c.Description))
} else if c.Summary != "" {
fmt.Fprintf(w, "%s\n\n", c.Summary)
}
fmt.Fprintf(w, "%s %s\n\n", c.Method, c.Path)
width := 0
for _, a := range c.Args {
width = max(width, len(a.Name)+2)
}
for _, f := range c.Flags {
width = max(width, len(f.Name)+len(f.Type)+3)
}
if len(c.Args) > 0 {
fmt.Fprintf(w, "Arguments:\n")
for _, a := range c.Args {
fmt.Fprintf(w, " %-*s %s\n", width, "<"+a.Name+">", a.Help)
}
fmt.Fprintln(w)
}
if len(c.Flags) > 0 {
fmt.Fprintf(w, "Flags:\n")
for _, f := range c.Flags {
help := f.Help
if f.Required {
help = strings.TrimSpace(help + " (required)")
}
fmt.Fprintf(w, " %-*s %s\n", width, "--"+f.Name+" "+f.Type, help)
}
fmt.Fprintln(w)
}
if ex := c.exampleLine(bin); ex != "" {
fmt.Fprintf(w, "Example:\n %s\n", ex)
}
}
// exampleLine renders the doc comment's Example body as the command line that
// sends it. The example in the reference and the example on the terminal are
// then the same value, projected twice.
func (c Command) exampleLine(bin string) string {
if len(c.Example) == 0 {
return ""
}
var m map[string]json.RawMessage
if json.Unmarshal(c.Example, &m) != nil {
return ""
}
flag := make(map[string]Flag, len(c.Flags))
for _, f := range c.Flags {
flag[f.Field] = f
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s %s", bin, c.Service, c.Name)
for _, a := range c.Args {
val := "<" + a.Name + ">"
if raw, ok := m[a.Name]; ok {
if s, err := strconv.Unquote(string(raw)); err == nil {
val = s
}
}
fmt.Fprintf(&b, " %s", val)
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
f, ok := flag[k]
if !ok {
continue
}
val := string(m[k])
if s, err := strconv.Unquote(val); err == nil {
val = s
}
if strings.ContainsAny(val, " \t\"'") {
val = strconv.Quote(val)
}
if f.Type == "boolean" && val == "true" {
fmt.Fprintf(&b, " --%s", f.Name)
continue
}
fmt.Fprintf(&b, " --%s %s", f.Name, val)
}
return b.String()
}
+440
View File
@@ -0,0 +1,440 @@
package zip_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// ---------------------------------------------------------------------------
// The types one service registers. Nothing here is CLI-specific: these are the
// ordinary In/Out types of ordinary typed handlers, and everything the command
// line shows is read back off them.
// ---------------------------------------------------------------------------
type listAppsIn struct {
Env string `json:"env"`
Health string `json:"health"`
Drift bool `json:"drift"`
Limit int `json:"limit"`
}
type listAppsOut struct {
Apps []string `json:"apps"`
}
type deployIn struct {
App string `json:"app"`
Env string `json:"env" validate:"required"`
Wait bool `json:"wait"`
}
type deployOut struct {
App string `json:"app"`
Env string `json:"env"`
Restarted bool `json:"restarted"`
}
// TestCLI_NameComesFromTheRoute pins the derivation of `<service> <operation>`
// from an op's identity. The name is a function of that one op, so adding an
// unrelated route can never rename an existing command.
func TestCLI_NameComesFromTheRoute(t *testing.T) {
cases := []struct {
method, path string
service, op string
}{
{"GET", "/v1/paas/apps", "paas", "apps-list"},
{"GET", "/v1/paas/apps/:app", "paas", "apps-get"},
{"POST", "/v1/paas/apps/:app/deploy", "paas", "apps-deploy"},
{"GET", "/v1/paas/apps/:app/deploy", "paas", "apps-deploy-get"},
{"POST", "/v1/billing/charge", "billing", "charge-create"},
{"GET", "/v1/billing/invoices", "billing", "invoices-list"},
{"DELETE", "/v1/keys/:id", "keys", "delete"},
{"POST", "/v1/keys", "keys", "create"},
{"PATCH", "/v2/crm/contacts/:id", "crm", "contacts-update"},
{"GET", "/v1/gpu/nodes/:node/jobs", "gpu", "nodes-jobs-list"},
}
for _, c := range cases {
app := zip.New(zip.Config{DisableStartupMessage: true})
register(app, c.method, c.path)
cmds := app.Commands()
if len(cmds) != 1 {
t.Fatalf("%s %s: got %d commands, want 1", c.method, c.path, len(cmds))
}
if cmds[0].Service != c.service || cmds[0].Name != c.op {
t.Errorf("%s %s → %q %q, want %q %q",
c.method, c.path, cmds[0].Service, cmds[0].Name, c.service, c.op)
}
}
}
// register adds one typed route by method, so the name table above can be a
// table rather than ten near-identical apps.
func register(app *zip.App, method, path string) {
fn := func(_ context.Context, _ *listAppsIn) (*listAppsOut, error) { return &listAppsOut{}, nil }
switch method {
case "GET":
zip.Get(app, path, fn)
case "POST":
zip.Post(app, path, fn)
case "PUT":
zip.Put(app, path, fn)
case "PATCH":
zip.Patch(app, path, fn)
case "DELETE":
zip.Delete(app, path, fn)
}
}
// TestCLI_FlagsAndHelpComeFromTheSource proves the whole command — arguments,
// flags, types, required-ness, prose and example — is read off the handler's
// types and its doc comment. None of it is written for the CLI.
func TestCLI_FlagsAndHelpComeFromTheSource(t *testing.T) {
app := deployApp(t)
var cmd zip.Command
for _, c := range app.Commands() {
if c.Service == "paas" && c.Name == "apps-deploy" {
cmd = c
}
}
if cmd.Name == "" {
t.Fatal("apps-deploy was not derived")
}
// The path parameter addresses the resource, so it is positional — and it is
// NOT also a flag, because that would be two ways to set one value.
if len(cmd.Args) != 1 || cmd.Args[0].Name != "app" {
t.Fatalf("args = %+v, want one <app>", cmd.Args)
}
if cmd.Args[0].Help != "App to redeploy." {
t.Errorf("arg help = %q, want the field's doc comment", cmd.Args[0].Help)
}
want := map[string]zip.Flag{
"env": {Name: "env", Field: "env", Type: "string", Help: "Lifecycle env: main|test|dev.", Required: true},
"wait": {Name: "wait", Field: "wait", Type: "boolean", Help: "Block until the rollout settles."},
}
if len(cmd.Flags) != len(want) {
t.Fatalf("flags = %+v, want %d", cmd.Flags, len(want))
}
for _, got := range cmd.Flags {
w, ok := want[got.Name]
if !ok {
t.Fatalf("unexpected flag --%s", got.Name)
}
if got != w {
t.Errorf("--%s = %+v, want %+v", got.Name, got, w)
}
}
// The summary is the doc comment's first sentence — Go's own convention
// already makes that a summary, so nobody writes a second one.
if !strings.HasPrefix(cmd.Summary, "Deploy restarts an app") || strings.Contains(cmd.Summary, "zero downtime") {
t.Errorf("summary = %q, want just the first sentence", cmd.Summary)
}
// The help renders the doc comment's own Example body as the command line
// that sends it: one example, two projections.
var out bytes.Buffer
cli := &zip.CLI{Name: "hanzo", Commands: app.Commands(), Out: &out}
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "--help"}); err != nil {
t.Fatalf("help: %v", err)
}
help := out.String()
for _, want := range []string{
"hanzo paas apps-deploy <app>",
"Deploy restarts an app",
"zero downtime", // the full description, not just the summary
"--env string", // flag name and type
"Lifecycle env: main|test|dev.", // the field's doc comment
"(required)",
"--wait boolean",
"POST /v1/paas/apps/:app/deploy",
"hanzo paas apps-deploy acme --env main --wait", // the Example, as a command
} {
if !strings.Contains(help, want) {
t.Errorf("help is missing %q\n---\n%s", want, help)
}
}
}
// deployApp is one service: two typed routes and the extraction cmd/zipdoc
// emits for them.
func deployApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "hanzo", DisableStartupMessage: true})
zip.Describe("POST /v1/paas/apps/:app/deploy", zip.Doc{
Description: "Deploy restarts an app. The rollout is a rolling restart, so it happens with zero downtime.",
Fields: map[string]string{
"deployIn.app": "App to redeploy.",
"deployIn.env": "Lifecycle env: main|test|dev.",
"deployIn.wait": "Block until the rollout settles.",
},
Example: json.RawMessage(`{"app":"acme","env":"main","wait":true}`),
Response: json.RawMessage(`{"app":"acme","env":"main","restarted":true}`),
})
zip.Post(app, "/v1/paas/apps/:app/deploy", func(_ context.Context, in *deployIn) (*deployOut, error) {
return &deployOut{App: in.App, Env: in.Env, Restarted: in.Wait}, nil
})
zip.Describe("GET /v1/paas/apps", zip.Doc{
Description: "ListApps returns the fleet board.",
Fields: map[string]string{"listAppsIn.env": "Filter by env."},
})
zip.Get(app, "/v1/paas/apps", func(_ context.Context, in *listAppsIn) (*listAppsOut, error) {
return &listAppsOut{Apps: []string{"acme:" + in.Env}}, nil
})
return app
}
// TestCLI_RunsTheRegisteredHandler proves a command IS the operation: the
// positional argument reaches the In field the path names, the flags reach
// theirs, and the handler's own output is what the CLI prints.
func TestCLI_RunsTheRegisteredHandler(t *testing.T) {
app := deployApp(t)
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main", "--wait"}); err != nil {
t.Fatalf("run: %v", err)
}
var got deployOut
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode %q: %v", out.String(), err)
}
if got.App != "acme" || got.Env != "main" || !got.Restarted {
t.Fatalf("handler saw %+v, want the argument and both flags", got)
}
}
// TestCLI_RefusesWhatTheTypeRefuses proves the CLI enforces exactly what the
// operation declares — no more, and nothing of its own.
func TestCLI_RefusesWhatTheTypeRefuses(t *testing.T) {
app := deployApp(t)
cases := []struct {
args []string
want string
}{
{[]string{"paas", "apps-deploy", "acme"}, "--env is required"},
{[]string{"paas", "apps-deploy", "acme", "--env", "main", "--nope", "x"}, "unknown flag --nope"},
{[]string{"paas", "apps-deploy", "--env", "main"}, "takes 1 argument"},
{[]string{"paas", "apps-list", "--limit", "many"}, "--limit wants an integer"},
{[]string{"paas", "nope"}, `unknown operation "nope"`},
{[]string{"nope"}, `unknown service "nope"`},
}
for _, c := range cases {
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
err := cli.Run(context.Background(), c.args)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("run %v = %v, want an error containing %q", c.args, err, c.want)
}
}
}
// TestCLI_ANewRouteIsANewCommand is the test that decides whether the CLI is
// really derived: register one more typed route and the command exists, with
// its flags and its help, having edited no CLI source at all.
func TestCLI_ANewRouteIsANewCommand(t *testing.T) {
app := deployApp(t)
before := len(app.Commands())
for _, c := range app.Commands() {
if c.Service == "billing" {
t.Fatalf("billing already exists")
}
}
// The one edit: a service adds a typed route.
type refundIn struct {
Invoice string `json:"invoice"`
Cents int `json:"cents" validate:"required"`
Reason string `json:"reason"`
}
type refundOut struct {
Refunded int `json:"refunded"`
}
zip.Describe("POST /v1/billing/invoices/:invoice/refund", zip.Doc{
Description: "Refund returns money for an invoice.",
Fields: map[string]string{"refundIn.cents": "Amount to refund, in cents."},
Example: json.RawMessage(`{"invoice":"inv_1","cents":1200}`),
})
zip.Post(app, "/v1/billing/invoices/:invoice/refund",
func(_ context.Context, in *refundIn) (*refundOut, error) {
return &refundOut{Refunded: in.Cents}, nil
})
cmds := app.Commands()
if len(cmds) != before+1 {
t.Fatalf("commands went %d → %d, want one more", before, len(cmds))
}
var got zip.Command
for _, c := range cmds {
if c.Service == "billing" && c.Name == "invoices-refund" {
got = c
}
}
if got.Name == "" {
t.Fatalf("the new route produced no `billing invoices-refund`: %v", names(cmds))
}
if len(got.Args) != 1 || got.Args[0].Name != "invoice" {
t.Errorf("args = %+v, want <invoice>", got.Args)
}
if len(got.Flags) != 2 {
t.Fatalf("flags = %+v, want --cents and --reason", got.Flags)
}
for _, f := range got.Flags {
if f.Name == "cents" && (!f.Required || f.Type != "integer" || f.Help != "Amount to refund, in cents.") {
t.Errorf("--cents = %+v, want a required integer with its doc comment", f)
}
}
// And it runs.
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"billing", "invoices-refund", "inv_1", "--cents", "1200"}); err != nil {
t.Fatalf("run the new command: %v", err)
}
if !strings.Contains(out.String(), `"refunded": 1200`) {
t.Fatalf("output = %q, want the handler's result", out.String())
}
}
func names(cmds []zip.Command) []string {
var out []string
for _, c := range cmds {
out = append(out, c.Service+" "+c.Name)
}
return out
}
// TestCLI_SpecAndRegistryAgree proves the two derivations are one derivation:
// the command tree read from the live registry and the command tree read from
// the document that registry generates are the same commands.
//
// The flags of a GET are compared only where the document carries them. A zip
// document describes what the WIRE accepts, and a typed GET's non-path inputs
// are not on the wire yet (they belong in the query string) — so the client
// tree offers what the service will actually honour, which is the honest thing
// for it to offer.
func TestCLI_SpecAndRegistryAgree(t *testing.T) {
app := deployApp(t)
spec, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatalf("marshal spec: %v", err)
}
fromSpec, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
fromRegistry := app.Commands()
if len(fromSpec) != len(fromRegistry) {
t.Fatalf("spec has %v, registry has %v", names(fromSpec), names(fromRegistry))
}
for i := range fromRegistry {
r, s := fromRegistry[i], fromSpec[i]
if r.Service != s.Service || r.Name != s.Name || r.Method != s.Method || r.Path != s.Path {
t.Fatalf("op %d: spec says %q %q (%s %s), registry says %q %q (%s %s)",
i, s.Service, s.Name, s.Method, s.Path, r.Service, r.Name, r.Method, r.Path)
}
if fmt.Sprint(r.Args) != fmt.Sprint(s.Args) {
t.Errorf("%s %s args: spec %v, registry %v", r.Service, r.Name, s.Args, r.Args)
}
if r.Summary != s.Summary {
t.Errorf("%s %s summary: spec %q, registry %q", r.Service, r.Name, s.Summary, r.Summary)
}
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "DELETE" {
continue
}
if fmt.Sprint(r.Flags) != fmt.Sprint(s.Flags) {
t.Errorf("%s %s flags: spec %v, registry %v", r.Service, r.Name, s.Flags, r.Flags)
}
if string(r.Example) != string(s.Example) {
t.Errorf("%s %s example: spec %s, registry %s", r.Service, r.Name, s.Example, r.Example)
}
}
}
// TestCLI_RemoteRunsAgainstARunningService is the client case end to end: a
// binary that links none of the service's code asks it what it can do, builds
// the command line from the answer, and runs a command against it.
func TestCLI_RemoteRunsAgainstARunningService(t *testing.T) {
app := deployApp(t)
addr := freeAddr(t)
go func() { _ = app.Listen("http://" + addr) }()
defer func() { _ = app.Shutdown() }()
waitHTTP(t, "http://"+addr+"/.well-known/openapi.json")
// Everything from here knows only an address.
remote := zip.Remote{Base: "http://" + addr, Header: map[string]string{"X-Test": "1"}}
spec, err := remote.Spec(context.Background())
if err != nil {
t.Fatalf("fetch spec: %v", err)
}
cmds, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
var out bytes.Buffer
cli := &zip.CLI{Name: "hanzo", Commands: cmds, Invoke: remote.Invoke, Out: &out}
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main", "--wait"}); err != nil {
t.Fatalf("remote run: %v", err)
}
var got deployOut
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode %q: %v", out.String(), err)
}
if got.App != "acme" || got.Env != "main" || !got.Restarted {
t.Fatalf("service saw %+v, want the argument and both flags", got)
}
// A required flag is enforced before anything leaves the machine — the
// document carries validate:"required" through as the schema's required.
out.Reset()
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme"}); err == nil ||
!strings.Contains(err.Error(), "--env is required") {
t.Fatalf("missing --env = %v, want the client to refuse it", err)
}
// A spec-derived command has no handler in this process, and says so rather
// than pretending.
out.Reset()
local := &zip.CLI{Name: "hanzo", Commands: cmds, Invoke: zip.LocalInvoke, Out: &out}
if err := local.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main"}); err == nil ||
!strings.Contains(err.Error(), "not registered in this process") {
t.Fatalf("local invoke of a remote command = %v, want a clear refusal", err)
}
}
// TestCLI_UsageListsServices proves the top level is derived too: no list of
// services is written anywhere.
func TestCLI_UsageListsServices(t *testing.T) {
app := deployApp(t)
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), nil); err != nil {
t.Fatalf("usage: %v", err)
}
if !strings.Contains(out.String(), "paas") || !strings.Contains(out.String(), "2 operations") {
t.Fatalf("usage = %q, want the derived service list", out.String())
}
out.Reset()
if err := cli.Run(context.Background(), []string{"paas"}); err != nil {
t.Fatalf("service usage: %v", err)
}
for _, want := range []string{"apps-deploy <app>", "apps-list", "Deploy restarts an app"} {
if !strings.Contains(out.String(), want) {
t.Errorf("service usage is missing %q\n---\n%s", want, out.String())
}
}
}
+194
View File
@@ -0,0 +1,194 @@
package zip
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// CommandsFromSpec derives the same command tree from an OpenAPI document —
// which is the registry in wire form, generated by buildOpenAPI from those very
// ops. It is what lets a client CLI be derived without linking the service: ask
// a running one what it can do, and every command it offers is a projection of
// a route that service actually registered.
//
// The two derivations meet in the same Command value and are read by the same
// runner, so a command spelled locally and a command spelled over the wire are
// the same command. TestCLI_SpecAndRegistryAgree pins that.
func CommandsFromSpec(spec []byte) ([]Command, error) {
var doc specDoc
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, fmt.Errorf("zip: parse spec: %w", err)
}
var cmds []Command
for path, methods := range doc.Paths {
for method, op := range methods {
method = strings.ToUpper(method)
if !isMethod(method) {
continue // "parameters" and other path-level keys are not operations
}
route := colonPath(path)
c := Command{
Service: "",
Method: method,
Path: route,
Summary: op.Summary,
Description: op.Description,
}
c.Service, c.Name = commandName(method, route)
if c.Summary == "" {
c.Summary = firstSentence(op.Description)
}
c.Args, c.Flags = doc.bind(op, colonParams(route))
if body := op.body(); body != nil {
c.Example = body.Example
}
cmds = append(cmds, c)
}
}
sortCommands(cmds)
return cmds, nil
}
// bind splits one spec operation into positional args and flags, the same split
// App.Commands makes over the Go type: path parameters address, everything else
// modifies.
func (d specDoc) bind(op specOp, params []string) ([]Arg, []Flag) {
help := map[string]string{}
for _, p := range op.Parameters {
help[p.Name] = p.Description
}
args := make([]Arg, 0, len(params))
for _, p := range params {
args = append(args, Arg{Name: p, Help: help[p]})
}
var flags []Flag
add := func(name, typ, description string, required bool) {
if isParam(params, name) {
return
}
flags = append(flags, Flag{
Name: kebab(name), Field: name, Type: specType(typ),
Help: description, Required: required,
})
}
// Query parameters are inputs too — an operation that takes its filters in
// the URL must offer them as flags, or the CLI silently loses half the API.
for _, p := range op.Parameters {
if p.In == "query" {
add(p.Name, p.Schema.Type, p.Description, p.Required)
}
}
if body := op.body(); body != nil {
s := d.resolve(body.Schema)
req := map[string]bool{}
for _, r := range s.Required {
req[r] = true
}
names := make([]string, 0, len(s.Properties))
for name := range s.Properties {
names = append(names, name)
}
sort.Strings(names) // a map has no order; a CLI must
for _, name := range names {
p := s.Properties[name]
add(name, p.Type, p.Description, req[name])
}
}
return args, flags
}
// resolve follows a $ref into components/schemas. One level is all a zip
// document ever emits: schemaOf inlines everything else.
func (d specDoc) resolve(s specSchema) specSchema {
const prefix = "#/components/schemas/"
if strings.HasPrefix(s.Ref, prefix) {
if got, ok := d.Components.Schemas[strings.TrimPrefix(s.Ref, prefix)]; ok {
return got
}
}
return s
}
type specDoc struct {
Paths map[string]map[string]specOp `json:"paths"`
Components struct {
Schemas map[string]specSchema `json:"schemas"`
} `json:"components"`
}
type specOp struct {
Summary string `json:"summary"`
Description string `json:"description"`
Parameters []specParam `json:"parameters"`
RequestBody *struct {
Content map[string]specMedia `json:"content"`
} `json:"requestBody"`
}
type specMedia struct {
Schema specSchema `json:"schema"`
Example json.RawMessage `json:"example"`
}
type specParam struct {
Name string `json:"name"`
In string `json:"in"`
Description string `json:"description"`
Required bool `json:"required"`
Schema specSchema `json:"schema"`
}
type specSchema struct {
Ref string `json:"$ref"`
Type string `json:"type"`
Description string `json:"description"`
Properties map[string]specSchema `json:"properties"`
Required []string `json:"required"`
}
// body returns the JSON request media, if the operation has one.
func (o specOp) body() *specMedia {
if o.RequestBody == nil {
return nil
}
if m, ok := o.RequestBody.Content["application/json"]; ok {
return &m
}
return nil
}
// specType maps a JSON Schema type to a flag kind. Compound values stay JSON,
// exactly as they do coming off a Go type.
func specType(t string) string {
switch t {
case "string", "integer", "number", "boolean":
return t
}
return "json"
}
func isMethod(s string) bool {
switch s {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
return true
}
return false
}
// colonPath converts an OpenAPI "{name}" template to the router's ":name" form,
// so a Command carries one spelling of a path however it was derived.
func colonPath(path string) string {
if !strings.Contains(path, "{") {
return path
}
parts := strings.Split(path, "/")
for i, p := range parts {
if strings.HasPrefix(p, "{") && strings.HasSuffix(p, "}") {
parts[i] = ":" + p[1:len(p)-1]
}
}
return strings.Join(parts, "/")
}
+102
View File
@@ -0,0 +1,102 @@
// Command zipdoc makes a doc comment the spec.
//
// It walks a package for typed registrations — zip.Get[In,Out](app, path, fn)
// and its Post/Put/Patch/Delete siblings — takes the doc comment on each handler
// and on each field of its In and Out types, and writes a file that hands them
// to zip.Describe at init. From there they are the operation's description, its
// fields' descriptions and its request/response examples in the OpenAPI spec
// zip serves, and in the MCP tools it exposes.
//
// The point is that nothing is written twice. A summary passed to WithSummary
// sitting under a doc comment that says the same thing is two places to change
// and one to forget; this pass removes the second place.
//
// Put this above the package's route registrations:
//
// //go:generate zipdoc
//
// and write handlers the way Go already asks you to:
//
// // ListInvoices returns every invoice for the caller's org, newest first.
// //
// // Example: {"org": "hanzo", "limit": 25}
// // Response: {"invoices": [{"id": "inv_1", "cents": 1200}]}
// func ListInvoices(ctx context.Context, in *ListIn) (*ListOut, error)
//
// A malformed Example or Response fails generation rather than shipping a spec
// that is wrong.
//
// Usage:
//
// zipdoc [-o name] [-check] [packages]
//
// With -check nothing is written and a file that no longer matches its source is
// an error. That is the CI gate: the spec is derived from the code, so it cannot
// drift from it.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/zap-proto/zip/internal/zipdoc"
)
func main() {
name := flag.String("o", zipdoc.DefaultFile, "name of the generated file, written in each package's own directory")
check := flag.Bool("check", false, "write nothing; fail if a generated file is out of date")
flag.Usage = func() {
fmt.Fprint(os.Stderr, "usage: zipdoc [-o name] [-check] [packages]\n\n"+
"Extracts the doc comments on zip's typed handlers into zip.Describe calls.\n"+
"Packages default to the current directory, as `go generate` runs it.\n\n")
flag.PrintDefaults()
}
flag.Parse()
if err := run(*name, *check, flag.Args()); err != nil {
fmt.Fprintln(os.Stderr, "zipdoc:", err)
os.Exit(1)
}
}
func run(name string, check bool, patterns []string) error {
if filepath.Base(name) != name {
return fmt.Errorf("-o takes a file name, not a path: %s", name)
}
dir, err := os.Getwd()
if err != nil {
return err
}
pkgs, err := zipdoc.Load(dir, patterns)
if err != nil {
return err
}
var stale []string
for _, p := range pkgs {
if check {
why, err := p.Stale(name)
if err != nil {
return err
}
if why != "" {
stale = append(stale, why)
}
continue
}
changed, err := p.Write(name)
if err != nil {
return err
}
if changed {
fmt.Fprintln(os.Stderr, "zipdoc:", filepath.Join(p.Dir, name))
}
}
if len(stale) > 0 {
return fmt.Errorf("%d generated file(s) do not match the source; run `go generate ./...`:\n\t%s",
len(stale), strings.Join(stale, "\n\t"))
}
return nil
}
+5 -5
View File
@@ -46,11 +46,11 @@ func TestDoc_ReachesTheSpec(t *testing.T) {
got := string(raw)
for _, want := range []string{
`newest first`, // description
`Maximum invoices to return.`, // In field doc
`Invoice ids, newest first.`, // Out field doc
`"limit":25`, // request example
`"invoices":["inv_2","inv_1"]`, // response example
`newest first`, // description
`Maximum invoices to return.`, // In field doc
`Invoice ids, newest first.`, // Out field doc
`"limit":25`, // request example
`"invoices":["inv_2","inv_1"]`, // response example
} {
if !strings.Contains(got, want) {
t.Errorf("spec is missing %q", want)
+6
View File
@@ -12,6 +12,12 @@ require (
github.com/zap-proto/go v1.3.0
)
require (
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.44.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
+6
View File
@@ -68,13 +68,19 @@ github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+26
View File
@@ -0,0 +1,26 @@
// Package jsontag resolves a struct field's name on the wire.
//
// It exists so that rule lives in ONE place. zip reads it three ways — from
// reflect when building a schema, from reflect when binding path params, and
// from the AST when cmd/zipdoc extracts field documentation — and a spec that
// names a field differently from the decoder that reads it is precisely the
// drift the generator exists to kill. Different inputs, same rule.
package jsontag
import "strings"
// Name is the JSON name of a field declared as goName with the given `json`
// struct tag value (the tag's value, not the whole tag literal).
//
// The tag's name wins; an empty or options-only tag (`json:",omitempty"`)
// leaves the Go name. "-" is returned as-is rather than resolved, so a caller
// can tell "this field is omitted" from "this field is named -".
func Name(goName, tag string) string {
if i := strings.IndexByte(tag, ','); i >= 0 {
tag = tag[:i]
}
if tag == "" {
return goName
}
return tag
}
+165
View File
@@ -0,0 +1,165 @@
package zipdoc
import (
"bytes"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Header marks the emitted file. It is the standard form `go generate` and every
// Go tool recognises, and the guard that stops this command from ever writing
// over something a person wrote.
const Header = "// Code generated by zipdoc; DO NOT EDIT."
// DefaultFile is the name written into each package's own directory, so the
// prose sits next to the source it came from and moves with it.
const DefaultFile = "zipdoc_gen.go"
// Render is the file for a package's operations, gofmt'd. Nil when the package
// registers nothing typed — there is no such thing as an empty documentation
// file, only one that should not exist.
func (p Package) Render() ([]byte, error) {
if len(p.Ops) == 0 {
return nil, nil
}
ops := append([]Op(nil), p.Ops...)
sort.Slice(ops, func(i, j int) bool { return ops[i].Key() < ops[j].Key() })
// zip documenting itself needs no import and no qualifier.
q := "zip."
if p.Path == ZipPkg {
q = ""
}
raw := false
for _, o := range ops {
raw = raw || o.Example != "" || o.Response != ""
}
var b strings.Builder
fmt.Fprintf(&b, "%s\n\npackage %s\n\n", Header, p.Name)
if raw || q != "" {
b.WriteString("import (\n")
if raw {
b.WriteString("\t\"encoding/json\"\n")
}
if q != "" {
if raw {
b.WriteString("\n")
}
fmt.Fprintf(&b, "\t%q\n", ZipPkg)
}
b.WriteString(")\n\n")
}
b.WriteString("func init() {\n")
for _, o := range ops {
fmt.Fprintf(&b, "\t%sDescribe(%q, %sDoc{\n", q, o.Key(), q)
if o.Description != "" {
fmt.Fprintf(&b, "\t\tDescription: %s,\n", strconv.Quote(o.Description))
}
if len(o.Fields) > 0 {
b.WriteString("\t\tFields: map[string]string{\n")
keys := make([]string, 0, len(o.Fields))
for k := range o.Fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "\t\t\t%q: %s,\n", k, strconv.Quote(o.Fields[k]))
}
b.WriteString("\t\t},\n")
}
if o.Example != "" {
fmt.Fprintf(&b, "\t\tExample: json.RawMessage(%s),\n", goString(o.Example))
}
if o.Response != "" {
fmt.Fprintf(&b, "\t\tResponse: json.RawMessage(%s),\n", goString(o.Response))
}
b.WriteString("\t})\n")
}
b.WriteString("}\n")
src, err := format.Source([]byte(b.String()))
if err != nil {
return nil, fmt.Errorf("generated source for %s does not parse: %w", p.Path, err)
}
return src, nil
}
// goString prefers a raw literal, so JSON reads as JSON, and falls back to a
// quoted one when the content would break out of it.
func goString(s string) string {
if !strings.ContainsAny(s, "`\r") {
return "`" + s + "`"
}
return strconv.Quote(s)
}
// Write puts the rendered file in the package directory, and removes a stale one
// when the package no longer registers anything. Reports whether it changed
// anything.
func (p Package) Write(name string) (bool, error) {
path := filepath.Join(p.Dir, name)
want, err := p.Render()
if err != nil {
return false, err
}
got, generated, err := read(path)
if err != nil {
return false, err
}
if bytes.Equal(got, want) {
return false, nil
}
if got != nil && !generated {
return false, fmt.Errorf("%s exists and was not generated by zipdoc; refusing to overwrite it", path)
}
if want == nil {
return true, os.Remove(path)
}
return true, os.WriteFile(path, want, 0o644)
}
// Stale reports why the file on disk does not match the source, or "" when it
// does. This is what CI runs: the spec is derived, so a stale file is a
// description that has drifted from the code it describes.
func (p Package) Stale(name string) (string, error) {
path := filepath.Join(p.Dir, name)
want, err := p.Render()
if err != nil {
return "", err
}
got, generated, err := read(path)
if err != nil {
return "", err
}
switch {
case bytes.Equal(got, want):
return "", nil
case got == nil:
return path + ": missing", nil
case !generated:
return path + ": not generated by zipdoc", nil
case want == nil:
return path + ": stale (the package no longer registers typed handlers)", nil
default:
return path + ": stale", nil
}
}
// read returns the file's bytes (nil if absent) and whether it carries the
// generated header.
func read(path string) ([]byte, bool, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return b, bytes.HasPrefix(b, []byte(Header)), nil
}
+477
View File
@@ -0,0 +1,477 @@
// Package zipdoc extracts the documentation a service has already written — the
// doc comments on its typed handlers and on the fields of their In/Out types —
// and emits the zip.Describe calls that carry it into the OpenAPI spec and the
// MCP tool list at run time.
//
// Go drops comments at compile time; reflection sees types and tags and never
// prose. So the ONLY way for a doc comment to reach the spec is a build-time
// pass over the AST, and this is it. cmd/zipdoc is the command around this
// package; the split is CLI (flags, files, exit codes) from extraction
// (source in, Doc out).
package zipdoc
import (
"bytes"
"encoding/json"
"fmt"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"go/types"
"path/filepath"
"reflect"
"strings"
"golang.org/x/tools/go/packages"
"github.com/zap-proto/zip/internal/jsontag"
)
// ZipPkg is the package whose generic registrars this pass recognises. Matching
// on the import path rather than on the local name means an aliased import, a
// dot import and a plain one all resolve the same.
const ZipPkg = "github.com/zap-proto/zip"
// verbs are the typed registrars and the method each one registers. An untyped
// app.Get has no In/Out and therefore no schema, no MCP tool and nothing here to
// document — the typed surface IS the documented surface.
var verbs = map[string]string{
"Get": "GET",
"Post": "POST",
"Put": "PUT",
"Patch": "PATCH",
"Delete": "DELETE",
}
// Op is one typed registration and everything its source says about it. The
// fields mirror zip.Doc — emit.go writes them straight into a zip.Doc literal,
// and a test pins the two shapes together.
type Op struct {
Method string
Path string
Description string
Fields map[string]string
// Example and Response are compacted JSON, "" when the comment gave none.
Example string
Response string
}
// Key is the operation's identity, the same one zip's registry uses.
func (o Op) Key() string { return o.Method + " " + o.Path }
// Package is one loaded package and the operations registered in it.
type Package struct {
Dir string // directory the generated file belongs in
Name string // package clause
Path string // import path, so a package inside zip itself skips the import
Ops []Op
}
// Load type-checks the packages matched by patterns (relative to dir) and
// extracts every typed registration in them.
//
// It insists the packages compile. Extraction reads resolved types — which
// generic instantiation a call is, which declaration a handler name refers to —
// so a package that does not type-check yields answers that are wrong rather
// than missing, and wrong is what this whole design exists to prevent.
func Load(dir string, patterns []string) ([]Package, error) {
if len(patterns) == 0 {
patterns = []string{"."}
}
fset := token.NewFileSet()
pkgs, err := packages.Load(&packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
packages.NeedImports | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo,
Dir: dir,
Fset: fset,
}, patterns...)
if err != nil {
return nil, err
}
var errs []string
for _, p := range pkgs {
for _, e := range p.Errors {
errs = append(errs, p.PkgPath+": "+e.Error())
}
}
if len(errs) > 0 {
if len(errs) > 5 {
errs = append(errs[:5], fmt.Sprintf("(+%d more)", len(errs)-5))
}
return nil, fmt.Errorf("packages do not compile:\n\t%s", strings.Join(errs, "\n\t"))
}
e := &extractor{load: fset, own: token.NewFileSet(), files: map[string]*ast.File{}}
out := make([]Package, 0, len(pkgs))
for _, p := range pkgs {
ops, err := e.pkg(p)
if err != nil {
return nil, err
}
files := p.GoFiles
if len(files) == 0 {
files = p.CompiledGoFiles
}
if len(files) == 0 {
continue
}
out = append(out, Package{
Dir: filepath.Dir(files[0]),
Name: p.Name,
Path: p.PkgPath,
Ops: ops,
})
}
return out, nil
}
// extractor holds the two views of the source it needs: the loader's positions,
// and its own re-parse of each file WITH comments, which is where the prose is.
// One parse per file, cached; both views index the same bytes, so a position in
// one is a byte offset in the other.
type extractor struct {
load *token.FileSet
own *token.FileSet
files map[string]*ast.File
}
func (e *extractor) pkg(p *packages.Package) ([]Op, error) {
var ops []Op
var err error
for _, f := range p.Syntax {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || err != nil {
return err == nil
}
op, found, cerr := e.call(p.TypesInfo, call)
if cerr != nil {
err = cerr
return false
}
if found {
ops = append(ops, op)
}
return true
})
}
return ops, err
}
// call reads one zip.Get[In,Out](app, path, handler) — or Post/Put/Patch/Delete
// — into an Op. Anything that is not such a call is reported as not found.
func (e *extractor) call(info *types.Info, call *ast.CallExpr) (Op, bool, error) {
id := calleeIdent(call.Fun)
if id == nil {
return Op{}, false, nil
}
fn, _ := info.Uses[id].(*types.Func)
if fn == nil || fn.Pkg() == nil || fn.Pkg().Path() != ZipPkg {
return Op{}, false, nil
}
method, ok := verbs[fn.Name()]
if !ok || len(call.Args) < 3 {
return Op{}, false, nil
}
// The instantiation, not the syntax: zip.Post(app, p, validate) infers In and
// Out from the handler and writes no type arguments at all, and a registration
// this pass cannot see is a hole in the spec.
args := info.Instances[id].TypeArgs
if args == nil || args.Len() != 2 {
return Op{}, false, nil
}
lit := info.Types[call.Args[1]].Value
if lit == nil || lit.Kind() != constant.String {
return Op{}, false, fmt.Errorf("%s: zip.%s route path is not a constant string, so the operation has no identity to document",
e.load.Position(call.Args[1].Pos()), fn.Name())
}
path := constant.StringVal(lit)
doc := e.handlerDoc(info, call.Args[2])
prose, example, response, err := splitDoc(doc)
if err != nil {
return Op{}, false, fmt.Errorf("%s: %s %s: %w", e.load.Position(call.Pos()), method, path, err)
}
op := Op{
Method: method,
Path: path,
Description: prose,
Example: example,
Response: response,
Fields: map[string]string{},
}
seen := map[*types.Named]bool{}
e.fields(args.At(0), op.Fields, seen)
e.fields(args.At(1), op.Fields, seen)
return op, true, nil
}
// calleeIdent is the identifier being called, past any parens and any explicit
// type arguments: zip.Get, zip.Get[A,B], Get and Get[A,B] all yield the Get.
func calleeIdent(fun ast.Expr) *ast.Ident {
fun = ast.Unparen(fun)
switch e := fun.(type) {
case *ast.IndexExpr:
fun = ast.Unparen(e.X)
case *ast.IndexListExpr:
fun = ast.Unparen(e.X)
}
switch e := fun.(type) {
case *ast.Ident:
return e
case *ast.SelectorExpr:
return e.Sel
}
return nil
}
// handlerDoc is the comment that documents the operation: the doc comment of the
// named handler, or — when the handler is written inline — the comment directly
// above the registration, which is the only place there is to write one.
func (e *extractor) handlerDoc(info *types.Info, arg ast.Expr) string {
switch h := ast.Unparen(arg).(type) {
case *ast.Ident, *ast.SelectorExpr:
var id *ast.Ident
if s, ok := h.(*ast.SelectorExpr); ok {
id = s.Sel
} else {
id = h.(*ast.Ident)
}
if fn, ok := info.Uses[id].(*types.Func); ok {
if d := e.funcDecl(fn.Pos()); d != nil {
return d.Doc.Text()
}
}
case *ast.FuncLit:
return e.commentAbove(arg.Pos())
}
return ""
}
// fields records the doc comment of every exported field of t, keyed exactly as
// zip's schema builder looks it up: the type's name, a dot, and the field's JSON
// name. It descends through pointers, slices, arrays and map values because the
// schema builder does, so a nested type's prose lands too.
func (e *extractor) fields(t types.Type, out map[string]string, seen map[*types.Named]bool) {
switch t := t.(type) {
case *types.Pointer:
e.fields(t.Elem(), out, seen)
case *types.Slice:
e.fields(t.Elem(), out, seen)
case *types.Array:
e.fields(t.Elem(), out, seen)
case *types.Map:
e.fields(t.Elem(), out, seen)
case *types.Named:
if seen[t] {
return
}
seen[t] = true
st, ok := t.Underlying().(*types.Struct)
if !ok {
e.fields(t.Underlying(), out, seen)
return
}
e.structFields(reflectName(t), t.Obj().Pos(), st, out)
for i := 0; i < st.NumFields(); i++ {
e.fields(st.Field(i).Type(), out, seen)
}
case *types.Struct:
// An anonymous struct has no name to key on — the schema builder inlines
// it — but its named field types still deserve their prose.
for i := 0; i < t.NumFields(); i++ {
e.fields(t.Field(i).Type(), out, seen)
}
}
}
// structFields pairs the type-checked fields (authoritative for names and tags)
// with the parsed ones (which carry the comments). go/types preserves source
// order, so index i is the same field in both.
func (e *extractor) structFields(name string, pos token.Pos, st *types.Struct, out map[string]string) {
spec := e.typeSpec(pos)
if spec == nil {
return
}
lit, ok := spec.Type.(*ast.StructType)
if !ok {
return
}
var flat []*ast.Field
for _, f := range lit.Fields.List {
n := len(f.Names)
if n == 0 {
n = 1 // embedded: one field, named for its type
}
for i := 0; i < n; i++ {
flat = append(flat, f)
}
}
if len(flat) != st.NumFields() {
return
}
for i := 0; i < st.NumFields(); i++ {
f := st.Field(i)
if !f.Exported() {
continue
}
jsonName := jsontag.Name(f.Name(), reflect.StructTag(st.Tag(i)).Get("json"))
if jsonName == "-" {
continue
}
doc := strings.TrimSpace(flat[i].Doc.Text())
if doc == "" {
doc = strings.TrimSpace(flat[i].Comment.Text())
}
if doc != "" {
out[name+"."+jsonName] = doc
}
}
}
// reflectName is the type's name as reflect.Type.Name reports it — which is what
// the schema builder keys on at run time. For an instantiated generic that
// includes the type arguments, qualified by package path.
func reflectName(n *types.Named) string {
name := n.Obj().Name()
args := n.TypeArgs()
if args == nil || args.Len() == 0 {
return name
}
parts := make([]string, args.Len())
for i := range parts {
parts[i] = types.TypeString(args.At(i), func(p *types.Package) string { return p.Path() })
}
return name + "[" + strings.Join(parts, ",") + "]"
}
// file is the comment-bearing parse of the file holding pos, with pos as a byte
// offset into it.
func (e *extractor) file(pos token.Pos) (*ast.File, int) {
p := e.load.Position(pos)
if p.Filename == "" {
return nil, 0
}
f, ok := e.files[p.Filename]
if !ok {
f, _ = parser.ParseFile(e.own, p.Filename, nil, parser.ParseComments|parser.SkipObjectResolution)
e.files[p.Filename] = f
}
return f, p.Offset
}
func (e *extractor) offset(pos token.Pos) int { return e.own.Position(pos).Offset }
// funcDecl is the declaration of the function whose name is at pos — in this
// package or any other, since a handler is as often a package away.
func (e *extractor) funcDecl(pos token.Pos) *ast.FuncDecl {
f, off := e.file(pos)
if f == nil {
return nil
}
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && e.offset(fd.Name.Pos()) == off {
return fd
}
}
return nil
}
// typeSpec is the declaration of the type whose name is at pos.
func (e *extractor) typeSpec(pos token.Pos) *ast.TypeSpec {
f, off := e.file(pos)
if f == nil {
return nil
}
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, s := range gd.Specs {
if ts, ok := s.(*ast.TypeSpec); ok && e.offset(ts.Name.Pos()) == off {
return ts
}
}
}
return nil
}
// commentAbove is the comment block immediately above the line at pos.
func (e *extractor) commentAbove(pos token.Pos) string {
f, _ := e.file(pos)
if f == nil {
return ""
}
want := e.load.Position(pos).Line - 1
for _, g := range f.Comments {
if e.own.Position(g.End()).Line == want {
return g.Text()
}
}
return ""
}
// splitDoc separates the prose from the Example:/Response: bodies.
//
// The bodies are part of the comment for the same reason the prose is: one
// place. A body may run onto the following lines — it is read until it parses —
// and is compacted, so the generated file is stable whatever the source
// formatting. Malformed JSON is an error, never a spec that ships broken.
func splitDoc(text string) (prose, example, response string, err error) {
lines := strings.Split(text, "\n")
var keep []string
for i := 0; i < len(lines); i++ {
key, rest, ok := marker(lines[i])
if !ok {
keep = append(keep, lines[i])
continue
}
body := rest
for j := i + 1; j < len(lines) && !json.Valid([]byte(body)); j++ {
if _, _, isMarker := marker(lines[j]); isMarker {
break
}
body += "\n" + lines[j]
i = j
}
compact, cerr := compactJSON(body)
if cerr != nil {
return "", "", "", fmt.Errorf("%s: %w", key, cerr)
}
if key == "Example" {
example = compact
} else {
response = compact
}
}
return strings.TrimSpace(strings.Join(keep, "\n")), example, response, nil
}
// marker recognises an "Example:" or "Response:" line and returns what follows.
func marker(line string) (key, rest string, ok bool) {
for _, k := range [...]string{"Example", "Response"} {
if r, found := strings.CutPrefix(strings.TrimSpace(line), k+":"); found {
return k, strings.TrimSpace(r), true
}
}
return "", "", false
}
// compactJSON validates and normalises one body. Compaction (rather than a
// decode/re-encode round trip) keeps the author's key order, and rejects
// trailing garbage as loudly as it rejects a missing brace.
func compactJSON(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("no JSON body")
}
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(s)); err != nil {
return "", fmt.Errorf("invalid JSON: %w", err)
}
return buf.String(), nil
}
+28
View File
@@ -0,0 +1,28 @@
// Package badexample compiles fine and documents itself wrongly: its Example is
// not JSON. Generation has to fail on it, because the alternative is a spec that
// ships an example nobody can send.
package badexample
import (
"context"
"github.com/zap-proto/zip"
)
// In is the request.
type In struct {
Name string `json:"name"`
}
// Out is the response.
type Out struct {
OK bool `json:"ok"`
}
// Register wires the one broken operation.
func Register(app *zip.App) { zip.Post(app, "/v1/things", Create) }
// Create makes a thing.
//
// Example: {"name": "ada",}
func Create(_ context.Context, in *In) (*Out, error) { return &Out{OK: in.Name != ""}, nil }
+97
View File
@@ -0,0 +1,97 @@
// Package fixture is a real zip service, registered every way zipdoc has to
// understand: a named handler, an inline one, explicit type arguments and
// inferred ones. Its committed zipdoc_gen.go is the assertion.
package fixture
import (
"context"
"github.com/zap-proto/zip"
)
//go:generate zipdoc
// ListIn asks for a page of invoices.
type ListIn struct {
// Org whose invoices to list.
Org string `json:"org" validate:"required"`
// Limit caps how many come back. Zero means the server's default.
Limit int `json:"limit,omitempty"`
// Cursor is opaque; pass back what the last page returned.
Cursor string `json:",omitempty"`
// Internal is tagged "-", so it is not on the wire and not in the spec.
Internal string `json:"-"`
// page is unexported, so it is neither.
page int
}
// ListOut is a page of invoices.
type ListOut struct {
Invoices []Invoice `json:"invoices"` // Invoices, newest first.
// Next is the cursor for the following page, empty at the end.
Next string `json:"next"`
}
// Invoice is one billed period. Nested, so its prose has to travel too.
type Invoice struct {
// ID is the invoice's stable identifier.
ID string `json:"id"`
// Cents is the amount owed, in the org's billing currency.
Cents int64 `json:"cents"`
Paid bool `json:"paid"`
}
// GetIn addresses one invoice.
type GetIn struct {
// ID comes from the URL.
ID string `json:"id"`
}
// VoidIn addresses the invoice to void.
type VoidIn struct {
// ID of the invoice to void.
ID string `json:"id"`
}
// Register wires the service. Paths are written once, here.
func Register(app *zip.App) {
zip.Post(app, "/v1/billing/invoices", ListInvoices)
zip.Get(app, "/v1/billing/invoices/:id", GetInvoice)
// VoidInvoice voids an invoice that has not been paid.
//
// The comment above an inline handler documents it, because there is
// nowhere else to put one.
//
// Response: {"ok": true}
zip.Delete[VoidIn, VoidOut](app, "/v1/billing/invoices/:id", func(_ context.Context, in *VoidIn) (*VoidOut, error) {
return &VoidOut{OK: in.ID != ""}, nil
})
}
// VoidOut reports the void.
type VoidOut struct {
// OK is true once the invoice is voided.
OK bool `json:"ok"`
}
// ListInvoices returns every invoice for the caller's org, newest first.
//
// Results are capped by limit, and a page carries the cursor for the next one.
//
// Example: {"org": "hanzo", "limit": 25}
//
// Response: {
// "invoices": [{"id": "inv_1", "cents": 1200, "paid": false}],
// "next": ""
// }
func ListInvoices(_ context.Context, in *ListIn) (*ListOut, error) {
return &ListOut{Invoices: []Invoice{{ID: "inv_1", Cents: 1200}}, Next: in.Cursor}, nil
}
// GetInvoice returns one invoice by id.
//
// Response: {"id": "inv_1", "cents": 1200, "paid": true}
func GetInvoice(_ context.Context, in *GetIn) (*Invoice, error) {
return &Invoice{ID: in.ID, Cents: 1200, Paid: true}, nil
}
+24 -15
View File
@@ -9,6 +9,7 @@ import (
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/internal/jsontag"
)
// OpenAPIConfig configures the auto-generated /.well-known/openapi.json
@@ -128,15 +129,30 @@ func (a *App) buildOpenAPI() map[string]any {
// 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)
describe := func(decl map[string]any, field string) map[string]any {
if help := fields[inName+"."+field]; help != "" {
decl["description"] = help
}
return decl
}
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, map[string]any{
decls = append(decls, describe(map[string]any{
"name": p, "in": "path", "required": true,
"schema": map[string]any{"type": "string"},
})
}, p))
}
// Query parameters. A bodyless method binds its input from the URL
// (typed.go bindURL), so every In field that is NOT already a path
@@ -149,10 +165,10 @@ func (a *App) buildOpenAPI() map[string]any {
if named[strings.ToLower(f.name)] {
continue
}
decls = append(decls, map[string]any{
decls = append(decls, describe(map[string]any{
"name": f.name, "in": "query", "required": false,
"schema": f.schema,
})
}, f.name))
}
}
if len(decls) > 0 {
@@ -390,18 +406,11 @@ func firstSentence(s string) string {
return s
}
// 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 {
tag := f.Tag.Get("json")
if tag == "" {
return f.Name
}
if i := strings.IndexByte(tag, ','); i >= 0 {
tag = tag[:i]
}
if tag == "" {
return f.Name
}
return tag
return jsontag.Name(f.Name, f.Tag.Get("json"))
}
// swaggerHTML is the minimal Swagger UI shell. Loads the UI from a CDN
+6 -6
View File
@@ -21,12 +21,12 @@ func stringReader(s string) io.Reader { return strings.NewReader(s) }
// searchIn exercises every kind the URL binder converts, plus a body-only
// field, plus a path param that shares its name with a query key.
type searchIn struct {
Owner string `json:"owner"` // path param on the member route
Q string `json:"q"`
Limit int `json:"limit"`
Offset uint32 `json:"offset"`
Score float64 `json:"score"`
Debug bool `json:"debug"`
Owner string `json:"owner"` // path param on the member route
Q string `json:"q"`
Limit int `json:"limit"`
Offset uint32 `json:"offset"`
Score float64 `json:"score"`
Debug bool `json:"debug"`
Ignored []string `json:"ignored"` // not a scalar: never URL-bound
}
+20 -1
View File
@@ -70,11 +70,30 @@ var (
Serve: func(addr string, h fasthttp.RequestHandler) Server {
return &httpServer{addr: addr, srv: &fasthttp.Server{Handler: h}}
},
Dial: func(addr string) Client { return &fasthttp.HostClient{Addr: addr} },
Dial: func(addr string) Client { return &fasthttp.HostClient{Addr: withPort(addr, "80")} },
},
// Dial-only: terminating TLS is the ingress's job, so nothing in the
// fleet serves https directly — but reaching something that does (an
// api.* host, a third party) is an ordinary Mount, and a CLI talking to
// a published API is the same call. One registry entry, both directions
// covered by the half that exists.
"https": {
Dial: func(addr string) Client {
return &fasthttp.HostClient{Addr: withPort(addr, "443"), IsTLS: true}
},
},
}
)
// withPort defaults the port of a tcp address, because a URL usually omits it
// and a dialer never can. A unix path is returned untouched.
func withPort(addr, port string) string {
if networkOf(addr) == "unix" || strings.LastIndex(addr, ":") > strings.LastIndex(addr, "]") {
return addr
}
return addr + ":" + port
}
// RegisterTransport adds (or replaces) a transport keyed by address scheme, so
// any future protocol slots into both Listen and Mount with ZERO change to
// either API. Call before Listen or Mount.
+1 -4
View File
@@ -116,10 +116,7 @@ func bindURL(in any, values map[string]string) {
if !fv.CanSet() {
continue
}
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "" {
name = f.Name
}
name := jsonFieldName(f)
for k, val := range values {
if strings.EqualFold(k, name) {
setScalar(fv, val)