mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
+3
-3
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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
@@ -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, "/")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user