mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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.
363 lines
10 KiB
Go
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, "/")
|
|
}
|