Files
zip/clispec.go
T
zeekay 5085333c13 Keep every existing op's wire and close the new kinds' edges
A JSON answer keeps fiber's application/json; charset=utf-8 unless the
op names another media, and every declared status carries the Out's
schema again, a union's alternative filed under the status it states.
A value of the op's own type stating status zero is refused as before;
only Body and Redirect read zero as the op's own. A header field is a
CLI flag under its field's name as it was, and a 3xx with a body is an
answer the remote CLI prints.

form: tags bind a form only when the op consumes a form media type, so
another binder's tags no longer move an op off JSON or refuse a GET.
A cookie: field is set by the request's cookie and nothing else, and no
body schema lists it, so a cross-site body cannot fill it.

Sse.Send takes a ctx that ends with the stream: a tool call collects
for at most toolWait and toolBound bytes, then cancels the producer,
and a stuck producer cannot hold the call. A panicking Reader ends its
answer, not the process. A socket op refuses a request that cannot
upgrade before its handler runs, over REST and the call plane, and a
nil CheckOrigin is same-origin, refused 403. The call plane carries a
union's JSON alternative; Sse and Socket fields that cannot cross say
so with zap:"-". MCP bytes are bounded and untyped bytes take the op's
media.
2026-10-09 09:42:01 -07:00

363 lines
10 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()
c.Consumes = op.consumes()
c.Stream = op.stream()
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, in string) {
if isParam(params, name) {
return
}
flags = append(flags, Flag{
Name: kebab(name), Field: name, Type: specType(typ),
Help: description, Required: required, In: in,
})
}
// A declared cookie is a flag that rides as one. 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 _, in := range []string{"cookie", "query"} {
for _, p := range op.Parameters {
if p.In == in {
where := in
if in == "query" {
where = ""
}
add(p.Name, p.Schema.Type, p.Description, p.Required, where)
}
}
}
if form := op.form(); form != nil {
s := d.resolve(form.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)
for _, name := range names {
p := s.Properties[name]
in, kind := "form", specType(p.Type)
if p.Format == "binary" || p.Items != nil && p.Items.Format == "binary" {
in, kind = "file", "file"
}
flags = append(flags, Flag{Name: kebab(name), Field: name, Type: kind, Help: p.Description, Required: req[name], In: in})
}
return args, flags
}
if op.bytes() {
flags = append(flags, Flag{Name: "body", Field: "body", Type: "file", Help: op.RequestBody.Description, In: "body"})
return args, flags
}
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 {
Description string `json:"description"`
Content map[string]specMedia `json:"content"`
} `json:"requestBody"`
// Socket is "websocket" for an op whose answer is an upgrade.
Socket string `json:"x-socket"`
// 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
}
// form returns the form request media, if the body is a form.
func (o specOp) form() *specMedia {
if o.RequestBody == nil {
return nil
}
for _, media := range []string{mimeMultipart, mimeForm} {
if m, ok := o.RequestBody.Content[media]; ok {
return &m
}
}
return nil
}
// bytes reports whether the request body is bytes taken as sent.
func (o specOp) bytes() bool {
if o.RequestBody == nil || o.body() != nil || o.form() != nil {
return false
}
for _, m := range o.RequestBody.Content {
if m.Schema.Format == "binary" {
return true
}
}
return false
}
// consumes are the request media when they are not application/json alone,
// sorted, as [ProjectCLI] spells them.
func (o specOp) consumes() []string {
if o.RequestBody == nil {
return nil
}
if _, ok := o.RequestBody.Content[mimeJSON]; ok && len(o.RequestBody.Content) == 1 {
return nil
}
out := make([]string, 0, len(o.RequestBody.Content))
for m := range o.RequestBody.Content {
out = append(out, m)
}
sort.Strings(out)
return out
}
// stream is the kind of answer that is not one JSON value: an upgrade, an
// event stream, or bytes.
func (o specOp) stream() string {
if o.Socket != "" {
return streamSocket
}
kind := ""
for code, r := range o.Responses {
if len(code) != 3 || code[0] != '2' {
continue
}
for media, m := range r.Content {
switch {
case media == "text/event-stream":
return streamSSE
case m.Schema.Format == "binary":
kind = streamBytes
}
}
}
return kind
}
// 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, "/")
}