Files
zip/clispec.go
T
zeekay 158ce60ada The registry renders the client that calls it
A hand-written client sends a method string somebody typed and a struct
somebody kept in step, and neither is checked: the Lux SDK addresses
eleven X-chain methods the node does not serve and sends chainID where
the node reads netID, with go build and go test both exiting 0.

So the registry renders it. App.SDK(pkg) is the eighth projection: one
method per op, calling over the op-call plane, with the op's own id as
the address. The name cannot be wrong because it is not written down.

It reads the REGISTRY and not the document, and that is the whole design
decision. A Go type has three shapes on the JSON edge it does not have on
the ZAP wire — properties have no order where a layout is declaration
order, json:"-" is absent from one and a slot on the other, and an
embedded struct is flattened by one and nested by the other — so a client
generated from the document lays its fields somewhere else and reads
every value after the first from the wrong offset. The generated struct
is therefore the declared struct, field for field, in order, with only
the type names changed; the layout is identical by construction rather
than by a rule to maintain.

An op whose In or Out has no wire form gets NO method, and says so in
Gaps. A client that could be called and could never succeed is worse than
one that reports the operation as out of reach.

A number in the document now states its width and sign as well as its
type. "integer" alone made every generated field a guess, and a guessed
width is not a mis-printed value, it is four bytes where eight were laid.
2026-08-29 14:36:44 -07:00

260 lines
7.8 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, pathParams(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 []pathParam) ([]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.Name, Help: help[p.Name]})
}
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 {
Info struct {
Title string `json:"title"`
Description string `json:"description"`
} `json:"info"`
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"`
// Responses is read by the SDK projection, which needs the ANSWER's type as
// well as the request's. A command does not: it prints whatever came back.
Responses map[string]struct {
Content map[string]specMedia `json:"content"`
} `json:"responses"`
}
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"`
// The rest is read by the SDK projection alone. A flag takes its value as
// text and hands it to the service, so a command needs only the JSON type;
// a Go field needs the width, the element and the encoding as well.
Format string `json:"format"`
ContentEncoding string `json:"contentEncoding"`
Items *specSchema `json:"items"`
AdditionalProperties *specSchema `json:"additionalProperties"`
}
// 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, "/")
}