Files
zip/cli.go
T
zeekayandHanzo Dev 5f3753e235 Honor ctx cancellation and deadlines on every outbound call
Call, remote invokes, proxy hops and CLI commands all called Client.Do,
which takes no ctx, so a cancelled or expired caller kept waiting until
the transport's own read timeout. zap-proto/http v0.3.11 adds
Transport.DoContext; a client that implements the new ContextClient
interface is now driven through it, and Call returns an error wrapping
context.Canceled or context.DeadlineExceeded.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 00:07:13 -07:00

1050 lines
34 KiB
Go

package zip
import (
"context"
"encoding/json"
"fmt"
"github.com/zap-proto/zip/internal/jsonenc"
"io"
"reflect"
"sort"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
// CLI — the FOURTH projection. The same typed-op registry (a.registry) 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.
//
// A command carries the op's OWN identity, not a second one: Command.OperationID
// is the token the document, the tool list and zip.Call all address it by, and
// an explicit WithOperationID renames the command with them.
//
// 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
// OperationID is the op's identity — the SAME token the OpenAPI document's
// operationId, the MCP tool's name and zip.Call all address it by. A command
// is a projection of an op, not a second thing with a second name, and this
// is what says WHICH op it projects.
OperationID 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 op's example input from the doc comment, rendered by the
// help as a runnable command line. It reaches a spec-derived command either
// from the request body or, for a bodyless method, rebuilt from the
// parameters the document had to split it across.
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 := ProjectCLI(a.Manifest())
// What a description cannot carry: the handler. A command spelled
// here runs in this process, so the op it projects is attached by the id
// both halves already address it by.
ops := make(map[string]*registeredOp, len(cmds))
for _, op := range a.Registry() {
ops[opName(op)] = op
}
for i := range cmds {
cmds[i].op = ops[cmds[i].OperationID]
}
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, id, summary string, doc Doc, has bool) Command {
svc, name := commandName(method, path, id)
c := Command{Service: svc, Name: name, OperationID: id, 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).
//
// body says the method carries one (hasBody). A bodyless op's input rides the
// URL, so its flags are exactly the URL-bindable fields — the SAME urlFields
// list the document reads. Offering the rest would offer flags the wire cannot
// carry: a `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
// value, and was dropped by the binder, so the command silently did nothing.
func bindIn(in reflect.Type, params []pathParam, fieldDocs map[string]string, body bool) ([]Arg, []Flag) {
args := make([]Arg, 0, len(params))
for _, p := range params {
// The argument a person types is the DOCUMENT's name: a wildcard's router
// key is *1, which is not something to ask anyone to write.
args = append(args, Arg{Name: p.Name, Help: fieldDocs[typeName(in)+"."+p.Name]})
}
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
// url is the name the URL carries the field under, which is what decides
// whether a flag would be a SECOND way to say what the path already says. It
// is not always the field's JSON name: a body field tagged `url:"-"` shares
// its name with a path param on purpose (the worker's source vs its name), and
// offering no flag for it left the command unable to send the body at all.
add := func(name, url, kind string, required bool) {
if name == "-" || isParam(params, url) {
return
}
flags = append(flags, Flag{
Name: kebab(name),
Field: name,
Type: kind,
Help: fieldDocs[t.Name()+"."+name],
Required: required,
})
}
if body {
// wireFields, so an embedded body type's promoted fields get flags. Reading
// only the outer type left a command unable to send them at all.
for _, f := range wireFields(t) {
add(jsonFieldName(f), urlFieldName(f), flagType(f.Type), strings.Contains(f.Tag.Get("validate"), "required"))
}
} else {
for _, f := range urlFields(t) {
kind, _ := f.schema["type"].(string)
add(f.name, f.name, specType(kind), f.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 []pathParam, name string) bool {
for _, p := range params {
if strings.EqualFold(p.Name, name) || strings.EqualFold(p.Key, name) {
return true
}
}
return false
}
// flagType is a flag's value kind, read off the ONE schema derivation: what a
// field IS on the wire is what schemaOf says it is, and specType is the SAME
// schema-type → flag-kind rule the spec-derived tree uses. A flag spelled from a
// Go type and the same flag spelled from that type's published schema are then
// equal by construction, rather than by two lists of kinds someone has to keep
// in step.
//
// Anything compound lands on 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. No registry is passed because none is needed — the kind of a
// struct is "object" whatever its fields are, and expanding them here would
// build a definition with nowhere to live.
func flagType(t reflect.Type) string {
kind, _ := schemaOf(t, nil, nil)["type"].(string)
return specType(kind)
}
// commandName derives `<service> <operation>` from an op's identity.
//
// The SERVICE is where the command lives in the tree: the first non-version
// static segment of the path, which is the same thing the router groups on.
//
// The OPERATION is the op's own name. An explicit id (WithOperationID) is that
// name — renaming an op has to rename it in EVERY projection or the command line
// addresses something the document has never heard of. Absent one, id is the
// method+path default and the words below spell it out long-hand:
//
// 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, id string) (service, name string) {
// An id that IS the default says nothing the path does not; only an
// explicit one renames the command.
if id == ID(method, path) {
id = ""
}
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", opWord("root", id, 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)
}
service = kebab(service)
return service, opWord(service, id, strings.Join(words, "-"))
}
// opWord is the operation token: the explicit id when there is one, else the
// name spelled out of the route. An explicit id is kebab-cased like any other
// identifier and loses a leading service word it would otherwise repeat —
// `billing_charge_create` under `billing` is `charge-create`, because the
// command line has already said billing.
func opWord(service, id, derived string) string {
if id == "" {
return derived
}
name := kebab(id)
if p := service + "-"; strings.HasPrefix(name, p) && len(name) > len(p) {
name = name[len(p):]
}
return name
}
// 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 and no headers: a command's arguments are named, so they bind
// as path values, and a CLI has neither a URL for the "?a=b" half nor a
// request to read headers from. A declared header field is simply supplied
// as an argument here — which is why the honest answer on a transport with
// no request is "nothing", not a panic.
return c.op.invoke(withOp(ctx, servedOp(c.op)), jsonenc.Unmarshal, body, nil, path, nil)
}
// 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 Proxy 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 bodyless method's non-path inputs belong in the query string, which is
// where the route's own decoder looks for them. hasBody is THE rule, read
// here as well as by the document and by the route itself.
if !hasBody(c.Method) && 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, bounded by ctx.
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)
// THE ESCAPING MUST SURVIVE THE TRANSPORT. Invoke percent-encodes every path
// argument (urlEscape) precisely so an argument cannot become part of the
// path, and fasthttp then undid it: its default serialisation is
// appendQuotedPath(u.Path()), and Path() has already decoded %2F and resolved
// "..". So `Invoke(cmd, {"app": "../../../v1/iam/users"})` on
// GET /v1/platform/apps/:app left as GET /v1/iam/users — one op's argument
// addressing another op entirely, on the caller's own credential.
//
// Set here so RequestURI() serialises PathOriginal(), the bytes Invoke built.
// It is stated AGAIN on the http/https HostClients (transport.go), and that is
// not a belt on a brace: HostClient.doNonNilReqResp ASSIGNS this field from
// its own, so for those two transports the request's own wish is overwritten
// and only the client's setting is read. This line is what carries the
// property over every OTHER transport — ZAP, the default, and anything
// RegisterTransport adds — which serialise the request as it was built.
//
// A command addresses ONE operation. That is the whole basis on which any
// caller decides whether it may run, and a transport that re-reads the address
// makes that decision about a different request than the one it sends.
req.URI().DisablePathNormalizing = true
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 := do(ctx, t.Dial(host), 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
}
// Query is the query string that carries in to a bodyless op.
//
// It is the client half of the URL binder and its exact inverse: what this
// writes, [bindURL] reads. That matters because they are two halves of one
// rule and they had drifted — a list went out as `?ids=["a","b"]` and a record
// as `?startIndex={"utxo":...}`, neither of which the binder reads, so the
// argument arrived empty and the op answered 200 about nothing.
//
// The value is taken from the op's own In, so the spelling is derived rather
// than agreed: a caller does not have to know that a list is comma-joined or
// that a record's leaves are named through it.
func Query(in any) (string, error) {
body, err := json.Marshal(in)
if err != nil {
return "", err
}
return queryOf(body)
}
// queryOf renders a JSON object as a query string, sorted so a command line
// always produces the same URL.
func queryOf(body []byte) (string, error) {
var b strings.Builder
if err := writeQuery(&b, "", body); err != nil {
return "", err
}
return b.String(), nil
}
// writeQuery writes one JSON object's keys under prefix, descending into a
// nested object the way [bindRecord] descends into a nested record.
func writeQuery(b *strings.Builder, prefix string, body []byte) 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)
for _, k := range keys {
raw := m[k]
if len(raw) == 0 || string(raw) == "null" {
continue // an absent value is written by leaving the key out
}
name := prefix + k
if raw[0] == '{' {
if err := writeQuery(b, name+".", raw); err != nil {
return err
}
continue
}
val, err := queryValue(raw)
if err != nil {
return err
}
if b.Len() > 0 {
b.WriteByte('&')
}
b.WriteString(urlEscape(name))
b.WriteByte('=')
b.WriteString(urlEscape(val))
}
return nil
}
// queryValue is one JSON value as the characters a URL carries: a string
// unquoted, a list comma-joined, anything else as it was written.
func queryValue(raw json.RawMessage) (string, error) {
if raw[0] == '[' {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return "", err
}
parts := make([]string, len(items))
for i, item := range items {
part, err := queryValue(item)
if err != nil {
return "", err
}
parts[i] = part
}
return strings.Join(parts, ","), nil
}
if s, err := strconv.Unquote(string(raw)); err == nil {
return s, nil
}
return string(raw), 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()
}