Files
zip/clispec.go
T
zeekayandHanzo Dev 8f6dcca49e wire: a bodyless method, spelled once — DELETE stops reading a body
BREAKING, on purpose. hasBody called itself "the ONE place that rule lives" and
was the only site that called it; three others re-stated it differently and
DELETE had drifted:

  - typed.go read a body for anything but GET/HEAD, so a typed Delete decoded a
    request the document says has none;
  - cli.go's Remote.Invoke SENT that body, so the CLI's remote path and its
    in-process path disagreed about one op;
  - cli.go's bindIn offered every field as a flag, including ones no URL can
    carry — `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
    value, and was dropped by the binder. The command silently did nothing.

All four now call hasBody. A typed Delete takes its input from the URL: path
params and query string, which is what every client generated from that document
already sent. It is a rule about the WIRE, not about the op — op.invoke still
decodes whatever JSON it is handed, because a call addressed by NAME (MCP
tools/call, zip.Call, a command) has no URL to carry half the input in.

Two things a bodyless op lost in the document are closed with it:

  - REQUIRED-NESS REACHES THE PARAMETER. urlField.required reads the same
    `validate:"required"` the body schema does. Every query parameter was
    hardcoded optional, so a field the handler refuses to run without was
    described as optional and every generated client made the argument optional
    to match — a document describing a call that cannot succeed.
  - THE EXAMPLE SURVIVES. A bodyless op has no requestBody for the doc comment's
    Example to live in, so it rides the parameters that carry its values and
    CommandsFromSpec rebuilds the object from them. Before this, every GET and
    DELETE reached the published reference and the generated CLI with no example
    at all.

TestCLI_SpecAndRegistryAgree is now plain equality over every field of every
command for every method. The skip is gone, and so is the divergence helper that
replaced it one release ago: there is no divergence left to pin.

Also: a validation failure names the field the way the CALLER named it, through
the same jsonFieldName rule the decoder, the schema and the flag all read.
Someone who sent `reason` was told `field "Reason"` is required — a name that
appears nowhere in the document they were working from.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:07:47 -07:00

243 lines
7.0 KiB
Go

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{
Method: method,
Path: route,
OperationID: op.OperationID,
Summary: op.Summary,
Description: op.Description,
}
c.Service, c.Name = commandName(method, route, op.OperationID)
if c.Summary == "" {
c.Summary = firstSentence(op.Description)
}
c.Args, c.Flags = doc.bind(op, colonParams(route))
c.Example = op.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 what a COMMAND
// needs: the body's own definition names the fields that become flags, and a
// field that is itself a named type is a compound value the CLI takes as JSON
// either way — so a deeper walk would resolve a reference to reach the same
// answer.
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 {
// OperationID is the op's identity in wire form. A command read off the
// document is the same command read off the registry BECAUSE both spell
// themselves from this token.
OperationID string `json:"operationId"`
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"`
Example json.RawMessage `json:"example"`
}
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
}
// example is the op's example input, reassembled from wherever the document had
// to put it: the request body carries the whole object, and a bodyless op has no
// body, so its example lives on the parameters that carry the values. One
// example either way — the same one the handler's doc comment wrote.
func (o specOp) example() json.RawMessage {
if body := o.body(); body != nil && len(body.Example) > 0 {
return body.Example
}
fields := map[string]json.RawMessage{}
for _, p := range o.Parameters {
if len(p.Example) > 0 {
fields[p.Name] = p.Example
}
}
if len(fields) == 0 {
return nil
}
// Sorted, because a map has no order and an example rendered twice must
// read the same both times.
names := make([]string, 0, len(fields))
for n := range fields {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
b.WriteByte('{')
for i, n := range names {
if i > 0 {
b.WriteByte(',')
}
key, err := json.Marshal(n)
if err != nil {
return nil
}
b.Write(key)
b.WriteByte(':')
b.Write(fields[n])
}
b.WriteByte('}')
return json.RawMessage(b.String())
}
// 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, "/")
}