mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
One registry, three projections. A typed op already produced a REST route, an OpenAPI path and an MCP tool; it now also produces a command. cli.go derives `<service> <operation>` from the op's own identity, so adding a route can never rename an existing command, and LocalInvoke runs it through the op's own invoke seam — a fused binary needs no client because the command IS the handler call. clispec.go builds the same commands from a served spec, which is what lets the CLI drive a service it was not linked against. cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist at run time, so prose reaches the spec only via a build-time AST pass. Field docs and examples are extracted from source and keyed "<Type>.<field>". Two defects found by making the projections agree: commandName treated "ends in a parameter" as the only way to address one thing, so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a collection, returning one deploy. The route already says which it is past its parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural. OpenAPI parameters carried no description while the registry had one, so the same argument had help in-process and none from the spec. Both now read the same key, and query parameters get it too. internal/jsontag exists so the reflect view of the wire-name rule and the AST view cannot drift; transport gains a dial-only https, because reaching a published API is an ordinary Mount and terminating TLS stays the ingress's job. Co-authored-by: Hanzo Dev <[email protected]>
166 lines
4.4 KiB
Go
166 lines
4.4 KiB
Go
package zipdoc
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"go/format"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Header marks the emitted file. It is the standard form `go generate` and every
|
|
// Go tool recognises, and the guard that stops this command from ever writing
|
|
// over something a person wrote.
|
|
const Header = "// Code generated by zipdoc; DO NOT EDIT."
|
|
|
|
// DefaultFile is the name written into each package's own directory, so the
|
|
// prose sits next to the source it came from and moves with it.
|
|
const DefaultFile = "zipdoc_gen.go"
|
|
|
|
// Render is the file for a package's operations, gofmt'd. Nil when the package
|
|
// registers nothing typed — there is no such thing as an empty documentation
|
|
// file, only one that should not exist.
|
|
func (p Package) Render() ([]byte, error) {
|
|
if len(p.Ops) == 0 {
|
|
return nil, nil
|
|
}
|
|
ops := append([]Op(nil), p.Ops...)
|
|
sort.Slice(ops, func(i, j int) bool { return ops[i].Key() < ops[j].Key() })
|
|
|
|
// zip documenting itself needs no import and no qualifier.
|
|
q := "zip."
|
|
if p.Path == ZipPkg {
|
|
q = ""
|
|
}
|
|
raw := false
|
|
for _, o := range ops {
|
|
raw = raw || o.Example != "" || o.Response != ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s\n\npackage %s\n\n", Header, p.Name)
|
|
if raw || q != "" {
|
|
b.WriteString("import (\n")
|
|
if raw {
|
|
b.WriteString("\t\"encoding/json\"\n")
|
|
}
|
|
if q != "" {
|
|
if raw {
|
|
b.WriteString("\n")
|
|
}
|
|
fmt.Fprintf(&b, "\t%q\n", ZipPkg)
|
|
}
|
|
b.WriteString(")\n\n")
|
|
}
|
|
b.WriteString("func init() {\n")
|
|
for _, o := range ops {
|
|
fmt.Fprintf(&b, "\t%sDescribe(%q, %sDoc{\n", q, o.Key(), q)
|
|
if o.Description != "" {
|
|
fmt.Fprintf(&b, "\t\tDescription: %s,\n", strconv.Quote(o.Description))
|
|
}
|
|
if len(o.Fields) > 0 {
|
|
b.WriteString("\t\tFields: map[string]string{\n")
|
|
keys := make([]string, 0, len(o.Fields))
|
|
for k := range o.Fields {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
fmt.Fprintf(&b, "\t\t\t%q: %s,\n", k, strconv.Quote(o.Fields[k]))
|
|
}
|
|
b.WriteString("\t\t},\n")
|
|
}
|
|
if o.Example != "" {
|
|
fmt.Fprintf(&b, "\t\tExample: json.RawMessage(%s),\n", goString(o.Example))
|
|
}
|
|
if o.Response != "" {
|
|
fmt.Fprintf(&b, "\t\tResponse: json.RawMessage(%s),\n", goString(o.Response))
|
|
}
|
|
b.WriteString("\t})\n")
|
|
}
|
|
b.WriteString("}\n")
|
|
|
|
src, err := format.Source([]byte(b.String()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generated source for %s does not parse: %w", p.Path, err)
|
|
}
|
|
return src, nil
|
|
}
|
|
|
|
// goString prefers a raw literal, so JSON reads as JSON, and falls back to a
|
|
// quoted one when the content would break out of it.
|
|
func goString(s string) string {
|
|
if !strings.ContainsAny(s, "`\r") {
|
|
return "`" + s + "`"
|
|
}
|
|
return strconv.Quote(s)
|
|
}
|
|
|
|
// Write puts the rendered file in the package directory, and removes a stale one
|
|
// when the package no longer registers anything. Reports whether it changed
|
|
// anything.
|
|
func (p Package) Write(name string) (bool, error) {
|
|
path := filepath.Join(p.Dir, name)
|
|
want, err := p.Render()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
got, generated, err := read(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if bytes.Equal(got, want) {
|
|
return false, nil
|
|
}
|
|
if got != nil && !generated {
|
|
return false, fmt.Errorf("%s exists and was not generated by zipdoc; refusing to overwrite it", path)
|
|
}
|
|
if want == nil {
|
|
return true, os.Remove(path)
|
|
}
|
|
return true, os.WriteFile(path, want, 0o644)
|
|
}
|
|
|
|
// Stale reports why the file on disk does not match the source, or "" when it
|
|
// does. This is what CI runs: the spec is derived, so a stale file is a
|
|
// description that has drifted from the code it describes.
|
|
func (p Package) Stale(name string) (string, error) {
|
|
path := filepath.Join(p.Dir, name)
|
|
want, err := p.Render()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
got, generated, err := read(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch {
|
|
case bytes.Equal(got, want):
|
|
return "", nil
|
|
case got == nil:
|
|
return path + ": missing", nil
|
|
case !generated:
|
|
return path + ": not generated by zipdoc", nil
|
|
case want == nil:
|
|
return path + ": stale (the package no longer registers typed handlers)", nil
|
|
default:
|
|
return path + ": stale", nil
|
|
}
|
|
}
|
|
|
|
// read returns the file's bytes (nil if absent) and whether it carries the
|
|
// generated header.
|
|
func read(path string) ([]byte, bool, error) {
|
|
b, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return b, bytes.HasPrefix(b, []byte(Header)), nil
|
|
}
|