mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
638 lines
17 KiB
Go
638 lines
17 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zip
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// The projections, read off a [Manifest] instead of off Go.
|
|
//
|
|
// These are the same documents [App.OpenAPISpec], [App.MCPTools] and
|
|
// [App.Commands] produce, derived from a description rather than from a
|
|
// reflect.Type — which is what lets a Rust or a C++ service have them. The rules
|
|
// they apply are the package's own and are not restated here: the operation id
|
|
// is [ID], the summary is [firstSentence], the path template is [Template], the
|
|
// parameters are [pathParams], the command tree is [CommandsFromSpec]. A rule
|
|
// stated twice is a rule that will be spelled two ways, and this package has the
|
|
// scar to prove it.
|
|
//
|
|
// The manifest-sourced document and the reflect-sourced one are pinned
|
|
// byte-identical over the corpus (TestProjectMatchesRegistry). That equality is
|
|
// the whole safety of the design: it says the manifest lost nothing, so a front
|
|
// end that can fill one in has everything Go has.
|
|
|
|
// ProjectOpenAPI is m as an OpenAPI 3.1 document.
|
|
func ProjectOpenAPI(m Manifest) map[string]any {
|
|
p := newProjector(m)
|
|
title := m.Title
|
|
if title == "" {
|
|
title = m.App
|
|
}
|
|
if title == "" {
|
|
title = "zip API"
|
|
}
|
|
version := m.Version
|
|
if version == "" {
|
|
version = "0.0.0"
|
|
}
|
|
|
|
ops := append([]ManifestOp(nil), m.Ops...)
|
|
sort.Slice(ops, func(i, j int) bool {
|
|
if ops[i].Path != ops[j].Path {
|
|
return ops[i].Path < ops[j].Path
|
|
}
|
|
return ops[i].Method < ops[j].Method
|
|
})
|
|
|
|
reg := newSchemaRegistry(specDefs)
|
|
paths := map[string]map[string]any{}
|
|
for _, op := range ops {
|
|
path := Template(op.Path)
|
|
if _, ok := paths[path]; !ok {
|
|
paths[path] = map[string]any{}
|
|
}
|
|
obj := map[string]any{"operationId": op.ID, "summary": op.Summary}
|
|
if op.Description != "" {
|
|
obj["description"] = op.Description
|
|
if op.Summary == "" {
|
|
obj["summary"] = firstSentence(op.Description)
|
|
}
|
|
}
|
|
if len(op.Tags) > 0 {
|
|
obj["tags"] = op.Tags
|
|
}
|
|
|
|
if p.hasRequestBody(op) {
|
|
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.In}, reg)}
|
|
if len(op.Example) > 0 {
|
|
media["example"] = json.RawMessage(op.Example)
|
|
}
|
|
obj["requestBody"] = map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{"application/json": media},
|
|
}
|
|
}
|
|
|
|
url := p.urlFields(op.In)
|
|
example := exampleFields(op.Example)
|
|
describe := func(decl map[string]any, docField, wire string) map[string]any {
|
|
if help := p.doc(op.In, docField); help != "" {
|
|
decl["description"] = help
|
|
}
|
|
if v, ok := example[wire]; ok {
|
|
decl["example"] = v
|
|
}
|
|
return decl
|
|
}
|
|
|
|
params := pathParams(op.Path)
|
|
decls := make([]any, 0, len(params))
|
|
named := make(map[string]bool, len(params))
|
|
for _, q := range params {
|
|
named[strings.ToLower(q.Name)] = true
|
|
named[strings.ToLower(q.Key)] = true
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": q.Name, "in": "path", "required": true,
|
|
"schema": url.paramSchema(q.Key),
|
|
}, url.docKey(q.Key), q.Key))
|
|
}
|
|
for _, h := range p.headerFields(op.In) {
|
|
named[strings.ToLower(h.field)] = true
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": h.header, "in": "header", "required": h.required,
|
|
"schema": h.schema,
|
|
}, h.field, h.field))
|
|
}
|
|
if !hasBody(op.Method) {
|
|
for _, f := range url {
|
|
if named[strings.ToLower(f.name)] {
|
|
continue
|
|
}
|
|
decls = append(decls, describe(map[string]any{
|
|
"name": f.name, "in": "query", "required": f.required,
|
|
"schema": f.schema,
|
|
}, url.docKey(f.name), f.name))
|
|
}
|
|
}
|
|
if len(decls) > 0 {
|
|
obj["parameters"] = decls
|
|
}
|
|
|
|
resp := map[string]any{}
|
|
if out := p.types[op.Out]; out != nil && out.Name != "" {
|
|
media := map[string]any{"schema": p.schema(TypeRef{Ref: op.Out}, reg)}
|
|
if len(op.Response) > 0 {
|
|
media["example"] = json.RawMessage(op.Response)
|
|
}
|
|
for _, code := range statuses(op.Statuses, 200) {
|
|
entry := map[string]any{
|
|
"description": statusText(code),
|
|
"content": map[string]any{"application/json": media},
|
|
}
|
|
if h := headerDecls(op); h != nil {
|
|
entry["headers"] = h
|
|
}
|
|
resp[strconv.Itoa(code)] = entry
|
|
}
|
|
} else {
|
|
for _, code := range statuses(op.Statuses, 204) {
|
|
entry := map[string]any{"description": statusText(code)}
|
|
if h := headerDecls(op); h != nil {
|
|
entry["headers"] = h
|
|
}
|
|
resp[strconv.Itoa(code)] = entry
|
|
}
|
|
}
|
|
if op.Gated {
|
|
if _, taken := resp["202"]; !taken {
|
|
resp["202"] = map[string]any{
|
|
"description": "held for approval",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{"schema": schemaOf(approvalType, reg, nil)},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
resp["default"] = refusalResponse(reg, op.OAuth)
|
|
obj["responses"] = resp
|
|
paths[path][strings.ToLower(op.Method)] = obj
|
|
}
|
|
|
|
return map[string]any{
|
|
"openapi": "3.1.0",
|
|
"info": map[string]any{
|
|
"title": title,
|
|
"description": m.Description,
|
|
"version": version,
|
|
},
|
|
"paths": paths,
|
|
"components": map[string]any{"schemas": reg.defs},
|
|
}
|
|
}
|
|
|
|
// ProjectMCP is m as an MCP tool list, in name order.
|
|
func ProjectMCP(m Manifest) []map[string]any {
|
|
p := newProjector(m)
|
|
tools := make([]map[string]any, 0, len(m.Ops))
|
|
for _, op := range m.Ops {
|
|
desc := op.Summary
|
|
if op.Description != "" {
|
|
desc = op.Description
|
|
}
|
|
annotations := map[string]any{}
|
|
if op.Method == "GET" || op.Method == "HEAD" {
|
|
annotations["readOnlyHint"] = true
|
|
}
|
|
tools = append(tools, map[string]any{
|
|
"name": op.ID,
|
|
"description": desc,
|
|
"inputSchema": p.rootSchema(op.In),
|
|
"annotations": annotations,
|
|
})
|
|
}
|
|
sort.Slice(tools, func(i, j int) bool {
|
|
return tools[i]["name"].(string) < tools[j]["name"].(string)
|
|
})
|
|
return tools
|
|
}
|
|
|
|
// ProjectCLI is m as a command tree.
|
|
//
|
|
// It reads the manifest rather than the document it also produces, and that is
|
|
// a deliberate difference from [CommandsFromSpec]: a document says which fields
|
|
// the BODY carries and which the URL does, and it cannot say that a body field
|
|
// opts OUT of the URL. A worker's source and its name are both spelled "script"
|
|
// — one in the path, one in the body — and a derivation that only sees the
|
|
// document drops the second as already-addressed, leaving a command that cannot
|
|
// send the body at all. The manifest carries both halves, so this one does not
|
|
// have to guess.
|
|
func ProjectCLI(m Manifest) []Command {
|
|
p := newProjector(m)
|
|
cmds := make([]Command, 0, len(m.Ops))
|
|
for _, op := range m.Ops {
|
|
c := Command{
|
|
Service: "", Name: "",
|
|
OperationID: op.ID,
|
|
Summary: op.Summary,
|
|
Description: op.Description,
|
|
Method: op.Method,
|
|
Path: op.Path,
|
|
Example: op.Example,
|
|
}
|
|
c.Service, c.Name = commandName(op.Method, op.Path, op.ID)
|
|
if c.Summary == "" {
|
|
c.Summary = firstSentence(op.Description)
|
|
}
|
|
c.Args, c.Flags = p.bind(op)
|
|
cmds = append(cmds, c)
|
|
}
|
|
sortCommands(cmds)
|
|
return cmds
|
|
}
|
|
|
|
// bind splits an op's input into positional args and flags: the URL addresses
|
|
// the resource, so what addresses it is positional and what modifies the
|
|
// request is a flag.
|
|
func (p *projector) bind(op ManifestOp) ([]Arg, []Flag) {
|
|
params := pathParams(op.Path)
|
|
td := p.types[op.In]
|
|
args := make([]Arg, 0, len(params))
|
|
for _, q := range params {
|
|
args = append(args, Arg{Name: q.Name, Help: p.doc(op.In, q.Name)})
|
|
}
|
|
if td == nil || td.Kind != "struct" {
|
|
return args, nil
|
|
}
|
|
var flags []Flag
|
|
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: p.doc(op.In, name),
|
|
Required: required,
|
|
})
|
|
}
|
|
if hasBody(op.Method) {
|
|
for _, f := range td.Fields {
|
|
add(f.JSON, urlName(f), p.flagType(f.Type), f.Required)
|
|
}
|
|
} else {
|
|
for _, f := range p.urlFields(op.In) {
|
|
kind, _ := f.schema["type"].(string)
|
|
add(f.name, f.name, specType(kind), f.required)
|
|
}
|
|
}
|
|
return args, flags
|
|
}
|
|
|
|
// flagType is a flag's value kind, read off the ONE schema derivation — the
|
|
// same rule [flagType] applies to a Go type, applied to a described one.
|
|
func (p *projector) flagType(r TypeRef) string {
|
|
kind, _ := p.schema(r, nil)["type"].(string)
|
|
return specType(kind)
|
|
}
|
|
|
|
// projector holds the type table while one document is assembled.
|
|
type projector struct {
|
|
types map[string]*TypeDesc
|
|
}
|
|
|
|
func newProjector(m Manifest) *projector {
|
|
p := &projector{types: make(map[string]*TypeDesc, len(m.Types))}
|
|
for i := range m.Types {
|
|
p.types[m.Types[i].ID] = &m.Types[i]
|
|
}
|
|
return p
|
|
}
|
|
|
|
// doc is the prose written for one field of a type.
|
|
func (p *projector) doc(id, field string) string {
|
|
td := p.types[id]
|
|
if td == nil {
|
|
return ""
|
|
}
|
|
for _, f := range td.Fields {
|
|
if f.JSON == field {
|
|
return f.Doc
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// schema is [schemaOf] over a described type: a named struct is defined in reg
|
|
// and referred to by $ref, everything else is inlined.
|
|
func (p *projector) schema(r TypeRef, reg *schemaRegistry) map[string]any {
|
|
switch {
|
|
case r.Ref != "":
|
|
td := p.types[r.Ref]
|
|
if td == nil {
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
if len(td.JSON) > 0 {
|
|
return decode(td.JSON)
|
|
}
|
|
switch td.Kind {
|
|
case "list":
|
|
return map[string]any{"type": "array", "items": p.schema(*td.Elem, reg)}
|
|
case "map":
|
|
return map[string]any{"type": "object", "additionalProperties": p.schema(*td.Elem, reg)}
|
|
case "struct":
|
|
if reg == nil {
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
if td.Name == "" {
|
|
out := map[string]any{}
|
|
p.fill(out, td, reg)
|
|
return out
|
|
}
|
|
return reg.ref(p.define(td, reg))
|
|
}
|
|
return map[string]any{"type": "object"}
|
|
case r.List != nil:
|
|
return map[string]any{"type": "array", "items": p.schema(*r.List, reg)}
|
|
case r.Map != nil:
|
|
return map[string]any{"type": "object", "additionalProperties": p.schema(*r.Map, reg)}
|
|
}
|
|
return primSchema(r.Prim)
|
|
}
|
|
|
|
// define claims td's entry before filling it, which is the cycle guard.
|
|
func (p *projector) define(td *TypeDesc, reg *schemaRegistry) string {
|
|
if name, ok := reg.byID[td.ID]; ok {
|
|
return name
|
|
}
|
|
name := td.ID
|
|
def := map[string]any{}
|
|
reg.byID[td.ID] = name
|
|
reg.defs[name] = def
|
|
p.fill(def, td, reg)
|
|
return name
|
|
}
|
|
|
|
// fill is [structSchema] over a described struct.
|
|
func (p *projector) fill(into map[string]any, td *TypeDesc, reg *schemaRegistry) {
|
|
props := map[string]any{}
|
|
var required []string
|
|
for _, f := range td.Fields {
|
|
if f.JSON == "-" {
|
|
continue
|
|
}
|
|
fs := p.schema(f.Type, reg)
|
|
if f.Doc != "" {
|
|
fs["description"] = f.Doc
|
|
}
|
|
props[f.JSON] = fs
|
|
if f.Required {
|
|
required = append(required, f.JSON)
|
|
}
|
|
}
|
|
into["type"] = "object"
|
|
into["properties"] = props
|
|
if len(required) > 0 {
|
|
into["required"] = required
|
|
}
|
|
}
|
|
|
|
// rootSchema is [rootSchemaOf] over a described type: the type inlined, with
|
|
// everything else it reaches carried alongside in $defs.
|
|
func (p *projector) rootSchema(id string) map[string]any {
|
|
if id == "" {
|
|
// An op that takes nothing has an EMPTY object for its arguments, not an
|
|
// unconstrained one: a tool whose schema says "any object" invites an
|
|
// agent to invent arguments the handler will never read.
|
|
return map[string]any{"type": "object", "properties": map[string]any{}}
|
|
}
|
|
reg := newSchemaRegistry(selfDefs)
|
|
root := p.schema(TypeRef{Ref: id}, reg)
|
|
ref, isRef := root["$ref"].(string)
|
|
if !isRef {
|
|
return root
|
|
}
|
|
name := strings.TrimPrefix(ref, reg.prefix)
|
|
root = maps.Clone(reg.defs[name].(map[string]any))
|
|
if reg.refs[name] == 1 {
|
|
delete(reg.defs, name)
|
|
}
|
|
if len(reg.defs) > 0 {
|
|
root["$defs"] = reg.defs
|
|
}
|
|
return root
|
|
}
|
|
|
|
// hasRequestBody is [hasRequestBody] over a described op.
|
|
func (p *projector) hasRequestBody(op ManifestOp) bool {
|
|
td := p.types[op.In]
|
|
if !hasBody(op.Method) || td == nil || td.Name == "" {
|
|
return false
|
|
}
|
|
if td.Kind != "struct" {
|
|
return true // the body IS the whole value.
|
|
}
|
|
named := map[string]bool{}
|
|
for _, q := range pathParams(op.Path) {
|
|
named[strings.ToLower(q.Name)] = true
|
|
named[strings.ToLower(q.Key)] = true
|
|
}
|
|
for _, f := range td.Fields {
|
|
if f.JSON == "-" {
|
|
continue
|
|
}
|
|
if !named[strings.ToLower(urlName(f))] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// urlFields is [urlFields] over a described input.
|
|
func (p *projector) urlFields(id string) urlFieldList {
|
|
var out urlFieldList
|
|
p.collectURL(id, "", map[string]bool{}, &out)
|
|
return out
|
|
}
|
|
|
|
func (p *projector) collectURL(id, prefix string, inside map[string]bool, out *urlFieldList) {
|
|
td := p.types[id]
|
|
if td == nil || td.Kind != "struct" || inside[id] {
|
|
return
|
|
}
|
|
inside[id] = true
|
|
defer delete(inside, id)
|
|
for _, f := range td.Fields {
|
|
name := urlName(f)
|
|
if name == "-" {
|
|
continue
|
|
}
|
|
name = prefix + name
|
|
if inner := p.record(f.Type); inner != "" {
|
|
p.collectURL(inner, name+".", inside, out)
|
|
continue
|
|
}
|
|
schema := p.urlSchema(f.Type)
|
|
if schema == nil {
|
|
continue
|
|
}
|
|
*out = append(*out, urlField{name: name, field: f.JSON, schema: schema, required: f.Required})
|
|
}
|
|
}
|
|
|
|
// record is the id of the struct a URL names the leaves of THROUGH this field,
|
|
// or "" when the URL carries the value itself.
|
|
func (p *projector) record(r TypeRef) string {
|
|
if r.Ref == "" {
|
|
return ""
|
|
}
|
|
td := p.types[r.Ref]
|
|
if td == nil || td.Kind != "struct" || td.Text || len(td.JSON) > 0 {
|
|
return ""
|
|
}
|
|
return td.ID
|
|
}
|
|
|
|
// urlSchema is [urlSchema] over a described type: what a URL can carry into it,
|
|
// and nil for what it cannot.
|
|
func (p *projector) urlSchema(r TypeRef) map[string]any {
|
|
switch {
|
|
case r.Ref != "":
|
|
td := p.types[r.Ref]
|
|
if td == nil {
|
|
return nil
|
|
}
|
|
if td.Text {
|
|
return map[string]any{"type": "string"}
|
|
}
|
|
switch td.Kind {
|
|
case "struct", "map":
|
|
return nil
|
|
case "list":
|
|
if item := p.urlSchema(*td.Elem); item != nil {
|
|
return map[string]any{"type": "array", "items": item}
|
|
}
|
|
return nil
|
|
}
|
|
if td.Repr == "bytes" || strings.HasPrefix(td.Repr, "bytes_fixed") {
|
|
return nil
|
|
}
|
|
return p.schema(r, nil)
|
|
case r.List != nil:
|
|
if item := p.urlSchema(*r.List); item != nil {
|
|
return map[string]any{"type": "array", "items": item}
|
|
}
|
|
return nil
|
|
case r.Map != nil:
|
|
return nil
|
|
}
|
|
switch r.Prim {
|
|
case "string", "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64":
|
|
return primSchema(r.Prim)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// headerFields is [headerFields] over a described input.
|
|
func (p *projector) headerFields(id string) []headerField {
|
|
td := p.types[id]
|
|
if td == nil || td.Kind != "struct" {
|
|
return nil
|
|
}
|
|
var out []headerField
|
|
for _, f := range td.Fields {
|
|
if f.Header == "" {
|
|
continue
|
|
}
|
|
out = append(out, headerField{
|
|
header: f.Header,
|
|
field: f.JSON,
|
|
required: f.Required,
|
|
schema: p.schema(f.Type, nil),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// urlName is the name a URL carries a field under.
|
|
func urlName(f FieldDesc) string {
|
|
if f.URL != "" {
|
|
return f.URL
|
|
}
|
|
return f.JSON
|
|
}
|
|
|
|
// statuses is [declaredStatuses] without an op to read it off.
|
|
func statuses(declared []int, dflt int) []int {
|
|
if len(declared) == 0 {
|
|
return []int{dflt}
|
|
}
|
|
return declared
|
|
}
|
|
|
|
// headerDecls is [responseHeaderSchemas] over a described op.
|
|
func headerDecls(op ManifestOp) map[string]any {
|
|
if len(op.ResponseHeaders) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]any, len(op.ResponseHeaders))
|
|
for _, name := range op.ResponseHeaders {
|
|
out[name] = map[string]any{
|
|
"description": "Set by " + op.Method + " " + op.Path + ".",
|
|
"schema": map[string]any{"type": "string"},
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// primSchema is [schemaOf]'s answer for a primitive, spelled from the manifest's
|
|
// vocabulary rather than from a reflect.Kind.
|
|
func primSchema(prim string) map[string]any {
|
|
switch prim {
|
|
case "bool":
|
|
return map[string]any{"type": "boolean"}
|
|
case "string":
|
|
return map[string]any{"type": "string"}
|
|
case "bytes":
|
|
return map[string]any{"type": "string", "contentEncoding": "base64"}
|
|
case "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64":
|
|
return map[string]any{"type": "integer", "format": intFormat(prim)}
|
|
case "f32":
|
|
return map[string]any{"type": "number", "format": "float"}
|
|
case "f64":
|
|
return map[string]any{"type": "number", "format": "double"}
|
|
}
|
|
if n, ok := fixedLen(prim); ok {
|
|
return map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{"type": "integer", "format": "uint8"},
|
|
// The width is the type, so it is stated rather than left to a reader
|
|
// to infer from a name it does not parse.
|
|
"minItems": n, "maxItems": n,
|
|
}
|
|
}
|
|
return map[string]any{"type": "object"}
|
|
}
|
|
|
|
// intFormat is the document's spelling of an integer's width and sign, which is
|
|
// what a fixed layout reads and "integer" alone does not carry.
|
|
func intFormat(prim string) string {
|
|
switch prim {
|
|
case "i64":
|
|
return "int64"
|
|
case "i32":
|
|
return "int32"
|
|
case "i16":
|
|
return "int16"
|
|
case "i8":
|
|
return "int8"
|
|
}
|
|
return strings.Replace(prim, "u", "uint", 1)
|
|
}
|
|
|
|
// fixedLen reads N out of bytes_fixed[N].
|
|
func fixedLen(prim string) (int, bool) {
|
|
if !strings.HasPrefix(prim, "bytes_fixed[") || !strings.HasSuffix(prim, "]") {
|
|
return 0, false
|
|
}
|
|
n, err := strconv.Atoi(prim[len("bytes_fixed[") : len(prim)-1])
|
|
return n, err == nil
|
|
}
|
|
|
|
// decode reads a schema a type stated for itself. A fresh copy every time, so
|
|
// one field's constraints cannot leak into another's through a shared map.
|
|
func decode(raw json.RawMessage) map[string]any {
|
|
var m map[string]any
|
|
if json.Unmarshal(raw, &m) != nil || m == nil {
|
|
return map[string]any{}
|
|
}
|
|
return m
|
|
}
|