Files
Ilker ManapandClaude Opus 5.5 a95c519bc5 The admin API described in OpenAPI 3.1: generated from a route table by docgen, served at /api/v1/openapi.json
adminapi.Routes lists every endpoint with its query parameters and Go values
of the bodies it reads and answers with; internal/docgen/openapi turns that
into docs/admin-api.openapi.json by reflection, the way encoding/json encodes
the types, with the fields' comments as descriptions. A test fails when an
endpoint is registered and not in the table, another when the committed
document is stale. The binary embeds it and serves it with the token.

Handlers that wrote map literals and decoded anonymous structs now use named
types, so the document can see them. GET /api/v1/models/{name} answers with a
model's whole definition as it is in force, which a tool managing it needs.
docs/admin-api.md: reaching the API, the token, what a request must carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:47:59 +02:00

673 lines
18 KiB
Go

// Package openapi generates the admin API's OpenAPI 3.1 document from its
// route table, adminapi.Routes.
//
// The schemas are read from the Go types the handlers decode and write, by
// reflection, following encoding/json's rules — tag names, omitted fields,
// embedded structs flattened — so the document says what goes over the wire
// rather than what someone remembered to write down. Their descriptions are
// the types' and fields' doc comments, read from the source, which is why
// generating needs the repository and is done by `go generate` rather than
// at run time: the binary serves the document built into it.
package openapi
import (
"bytes"
"encoding"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path"
"path/filepath"
"reflect"
"slices"
"sort"
"strings"
"time"
"unicode"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// Module is the import path the repository's packages are under; a type
// from any other is described without comments.
const Module = "gitea.mynodes.xyz/ilker/llmgw"
// Generate renders the document for routes, reading doc comments from the
// repository at root.
func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}}
// Every named struct reachable from a body first, so a name two
// packages share can be told apart before either is written: by the name
// alone where it is unique, with its package where it is not.
for _, r := range routes {
for _, v := range []any{r.Request, r.Response} {
if v != nil {
g.collect(reflect.TypeOf(v))
}
}
}
g.collect(errorType)
g.assignNames()
errorSchema := g.schema(errorType)
paths := map[string]*object{}
var order []string
tags := map[string]bool{}
for _, r := range routes {
item, ok := paths[r.Path]
if !ok {
item = &object{}
paths[r.Path] = item
order = append(order, r.Path)
}
op, err := g.operation(r, pathParams)
if err != nil {
return nil, err
}
tags[tagOf(r.Path)] = true
item.set(strings.ToLower(r.Method), op)
}
slices.Sort(order)
var pathsObj object
for _, p := range order {
pathsObj.set(p, *paths[p])
}
var schemas object
names := make([]string, 0, len(g.schemas))
for n := range g.schemas {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
schemas.set(n, g.schemas[n])
}
var tagList []any
for _, t := range sortedKeys(tags) {
tagList = append(tagList, obj("name", t))
}
doc := obj(
"openapi", "3.1.0",
"info", obj(
"title", "llmgw admin API",
"version", "v1",
"description", infoDescription,
),
"servers", []any{obj("url", "http://127.0.0.1:9090",
"description", "The admin listener at its default address, admin_listen.")},
"security", []any{obj("adminToken", []any{})},
"tags", tagList,
"paths", pathsObj,
"components", obj(
"securitySchemes", obj("adminToken", obj(
"type", "http",
"scheme", "bearer",
"description", "The admin token: the contents of /var/lib/llmgw/admin-token on the "+
"control plane (admin_token_file), readable by root and the server. "+
"Sent as Authorization: Bearer TOKEN.",
)),
"parameters", obj("Actor", obj(
"name", "X-Llmgw-Actor",
"in", "header",
"required", false,
"description", "Who the change is recorded as made by in the audit log: letters, digits "+
"and ._-@, at most 64. unknown when absent.",
"schema", obj("type", "string"),
)),
"responses", obj(
"Error", obj("description", "Refused: the error says why.",
"content", obj("application/json", obj("schema", errorSchema))),
"Unauthorized", obj("description", "The admin token is missing or wrong.",
"content", obj("application/json", obj("schema", errorSchema))),
),
"schemas", schemas,
),
)
b, err := marshal(doc)
if err != nil {
return nil, err
}
var out bytes.Buffer
if err := json.Indent(&out, b, "", " "); err != nil {
return nil, err
}
out.WriteByte('\n')
return out.Bytes(), nil
}
const infoDescription = "The control plane's management API: what `llmgw ctl` and the console use.\n\n" +
"It is served on the admin listener (admin_listen, 127.0.0.1:9090 by default), which stays on " +
"loopback unless admin_remote is set: reach it from elsewhere through an SSH tunnel. Every path " +
"under /api needs the admin token as a bearer token.\n\n" +
"The listener also refuses, before the token is looked at: a Host header that is a name rather " +
"than an address or localhost; a request another site's page sent; and any method but GET and " +
"HEAD whose Content-Type is not application/json, even one with no body.\n\n" +
"Durations in bodies are integer nanoseconds, as Go encodes them. Generated from the route " +
"table in internal/control/adminapi; see docs/admin-api.md."
// operation builds one route's operation object.
func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) {
var op object
op.set("operationId", operationID(r.Method, r.Path))
op.set("summary", r.Summary)
if r.Description != "" {
op.set("description", r.Description)
}
op.set("tags", []any{tagOf(r.Path)})
var params []any
segs := strings.Split(strings.Trim(r.Path, "/"), "/")
for i, s := range segs {
if !strings.HasPrefix(s, "{") {
continue
}
name := strings.Trim(s, "{}")
key := s
if i > 0 {
key = segs[i-1] + "/" + s
}
desc, ok := pathParams[key]
if !ok {
return nil, fmt.Errorf("%s %s: no description of %s in adminapi.PathParams", r.Method, r.Path, key)
}
params = append(params, obj("name", name, "in", "path", "required", true,
"description", desc, "schema", obj("type", "string")))
}
for _, q := range r.Query {
t := q.Type
if t == "" {
t = "string"
}
p := obj("name", q.Name, "in", "query")
if q.Required {
p.set("required", true)
}
p.set("description", q.Description)
p.set("schema", obj("type", t))
params = append(params, p)
}
if r.Method != "GET" && r.Method != "HEAD" {
params = append(params, obj("$ref", "#/components/parameters/Actor"))
}
if len(params) > 0 {
op.set("parameters", params)
}
if r.Request != nil {
body := obj("required", !r.OptionalBody,
"content", obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Request)))))
op.set("requestBody", body)
}
status := r.Status
if status == 0 {
status = 200
}
content := obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response))))
if r.CSV {
content.set("text/csv", obj("schema", obj("type", "string")))
}
desc := "OK"
if status == 202 {
desc = "Accepted: started, and running on."
}
op.set("responses", obj(
fmt.Sprint(status), obj("description", desc, "content", content),
"401", obj("$ref", "#/components/responses/Unauthorized"),
"default", obj("$ref", "#/components/responses/Error"),
))
return op, nil
}
// operationID is the method and the path's words, getModelsNameTarget.
func operationID(method, p string) string {
var b strings.Builder
b.WriteString(strings.ToLower(method))
for _, s := range strings.Split(strings.TrimPrefix(p, "/api/v1/"), "/") {
for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
b.WriteString(strings.ToUpper(w[:1]) + w[1:])
}
}
return b.String()
}
// tagOf groups an operation by the first word of its path.
func tagOf(p string) string {
first, _, _ := strings.Cut(strings.TrimPrefix(p, "/api/v1/"), "/")
return strings.TrimSuffix(first, ".json")
}
// --- schemas ---
type typeDoc struct {
doc string
fields map[string]string
}
type generator struct {
root string
docs map[string]map[string]typeDoc
names map[reflect.Type]string
schemas map[string]any
seen []reflect.Type
}
var (
timeType = reflect.TypeOf(time.Time{})
durationType = reflect.TypeOf(time.Duration(0))
rawType = reflect.TypeOf(json.RawMessage{})
errorType = reflect.TypeOf(adminapi.ErrorView{})
marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
)
// collect records every named struct reachable from t.
func (g *generator) collect(t reflect.Type) {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array || t.Kind() == reflect.Map {
if t == rawType {
return
}
t = t.Elem()
}
if t.Kind() != reflect.Struct || special(t) {
return
}
if t.Name() != "" {
if slices.Contains(g.seen, t) {
return
}
g.seen = append(g.seen, t)
}
for _, f := range fields(t) {
g.collect(f.typ)
}
}
// assignNames names each struct's schema: by its name alone when it is the
// admin API's own, with its package's otherwise — catalog.Model,
// modelserve.Job. Qualified always rather than only on a collision, so a
// type added elsewhere one day cannot rename a schema clients already use.
func (g *generator) assignNames() {
own := reflect.TypeOf(adminapi.Route{}).PkgPath()
for _, t := range g.seen {
n := t.Name()
if t.PkgPath() != own {
n = path.Base(t.PkgPath()) + "." + n
}
g.names[t] = n
}
g.schemas = map[string]any{}
}
func (g *generator) nameOf(t reflect.Type) string { return g.names[t] }
// special is a type encoded other than by its fields.
func special(t reflect.Type) bool {
if t == timeType || t == durationType || t == rawType {
return true
}
return t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType) ||
t.Implements(textType) || reflect.PointerTo(t).Implements(textType)
}
func ref(name string) object { return obj("$ref", "#/components/schemas/"+name) }
// schema describes t, adding the named structs it reaches to the components.
func (g *generator) schema(t reflect.Type) any {
switch {
case t == timeType:
return obj("type", "string", "format", "date-time")
case t == durationType:
return obj("type", "integer", "format", "int64", "description", "A duration, in nanoseconds.")
case t == rawType:
return obj("description", "Any JSON value.")
case t.Implements(textType) || reflect.PointerTo(t).Implements(textType):
return obj("type", "string")
case t.Implements(marshalerType) || reflect.PointerTo(t).Implements(marshalerType):
return obj("description", "Encoded by the type's own JSON encoding.")
}
switch t.Kind() {
case reflect.Bool:
return obj("type", "boolean")
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Uint8, reflect.Uint16, reflect.Uint32:
return obj("type", "integer")
case reflect.Int64, reflect.Uint, reflect.Uint64:
return obj("type", "integer", "format", "int64")
case reflect.Float32, reflect.Float64:
return obj("type", "number")
case reflect.String:
return obj("type", "string")
case reflect.Pointer:
return g.schema(t.Elem())
case reflect.Slice, reflect.Array:
if t.Elem().Kind() == reflect.Uint8 {
return obj("type", "string", "contentEncoding", "base64")
}
return obj("type", "array", "items", g.schema(t.Elem()))
case reflect.Map:
return obj("type", "object", "additionalProperties", g.schema(t.Elem()))
case reflect.Interface:
return obj()
case reflect.Struct:
if t.Name() == "" {
return g.structSchema(t, "")
}
name := g.names[t]
if name == "" {
// Reached only through a body collect did not walk, which would be
// a bug here; named anyway rather than inlined so it shows.
name = t.Name()
g.names[t] = name
}
if _, ok := g.schemas[name]; !ok {
g.schemas[name] = nil // placeholder: a type that contains itself
g.schemas[name] = g.structSchema(t, g.typeDocOf(t).doc)
}
return ref(name)
}
return obj()
}
func (g *generator) structSchema(t reflect.Type, doc string) object {
s := obj("type", "object")
if doc != "" {
s.set("description", doc)
}
var props object
for _, f := range fields(t) {
var p any
if f.asString {
p = obj("type", "string")
} else {
p = g.schema(f.typ)
}
if d := g.typeDocOf(f.owner).fields[f.goName]; d != "" {
p = withDescription(p, d)
}
props.set(f.name, p)
}
s.set("properties", props)
return s
}
// withDescription puts a field's comment on its schema. A reference keeps
// its own description where it is declared; in 3.1 a sibling description
// beside $ref is allowed and is the field's.
func withDescription(p any, d string) any {
o, ok := p.(object)
if !ok {
return p
}
if prev, ok := o.get("description"); ok {
d = d + "\n\n" + prev.(string)
}
o = slices.Clone(o)
o.set("description", d)
return o
}
// field is one key of a struct's JSON.
type field struct {
name string
goName string
owner reflect.Type // the struct it is declared in, for its comment
typ reflect.Type
asString bool
depth int
}
// fields lists a struct's JSON keys as encoding/json writes them: exported
// fields, by tag name, "-" left out, embedded structs without a name of
// their own flattened, and a key at a shallower depth winning over the same
// key from deeper in.
func fields(t reflect.Type) []field {
var out []field
var walk func(t reflect.Type, depth int)
walk = func(t reflect.Type, depth int) {
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
tag := sf.Tag.Get("json")
if tag == "-" {
continue
}
name, opts, _ := strings.Cut(tag, ",")
ft := sf.Type
if sf.Anonymous && name == "" {
et := ft
if et.Kind() == reflect.Pointer {
et = et.Elem()
}
if et.Kind() == reflect.Struct && !special(et) {
walk(et, depth+1)
continue
}
}
if !sf.IsExported() {
continue
}
if name == "" {
name = sf.Name
}
out = append(out, field{name: name, goName: sf.Name, owner: t, typ: ft, depth: depth,
asString: slices.Contains(strings.Split(opts, ","), "string")})
}
}
walk(t, 0)
// The shallowest of each name, in the order they were met.
best := map[string]int{}
for _, f := range out {
if d, ok := best[f.name]; !ok || f.depth < d {
best[f.name] = f.depth
}
}
var kept []field
taken := map[string]bool{}
for _, f := range out {
if f.depth == best[f.name] && !taken[f.name] {
taken[f.name] = true
kept = append(kept, f)
}
}
return kept
}
// typeDocOf reads a type's comment and its fields' from its package's source.
func (g *generator) typeDocOf(t reflect.Type) typeDoc {
pkg := t.PkgPath()
if pkg != Module && !strings.HasPrefix(pkg, Module+"/") {
return typeDoc{}
}
docs, ok := g.docs[pkg]
if !ok {
docs = parseDocs(filepath.Join(g.root, filepath.FromSlash(strings.TrimPrefix(strings.TrimPrefix(pkg, Module), "/"))))
g.docs[pkg] = docs
}
return docs[t.Name()]
}
// parseDocs reads the struct types declared in a directory and their
// comments. A directory it cannot read gives no comments rather than an
// error: the document is still right, only quieter.
func parseDocs(dir string) map[string]typeDoc {
out := map[string]typeDoc{}
entries, err := os.ReadDir(dir)
if err != nil {
return out
}
fset := token.NewFileSet()
for _, e := range entries {
n := e.Name()
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, filepath.Join(dir, n), nil, parser.ParseComments)
if err != nil {
continue
}
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, sp := range gd.Specs {
ts := sp.(*ast.TypeSpec)
doc := ts.Doc
if doc == nil && len(gd.Specs) == 1 {
doc = gd.Doc
}
td := typeDoc{doc: text(doc), fields: map[string]string{}}
if st, ok := ts.Type.(*ast.StructType); ok {
for _, fl := range st.Fields.List {
c := fl.Doc
if c == nil {
c = fl.Comment
}
for _, name := range fieldNames(fl) {
td.fields[name] = text(c)
}
}
}
out[ts.Name.Name] = td
}
}
}
return out
}
func fieldNames(fl *ast.Field) []string {
if len(fl.Names) > 0 {
var out []string
for _, n := range fl.Names {
out = append(out, n.Name)
}
return out
}
// Embedded: named by its type.
t := fl.Type
if s, ok := t.(*ast.StarExpr); ok {
t = s.X
}
switch x := t.(type) {
case *ast.Ident:
return []string{x.Name}
case *ast.SelectorExpr:
return []string{x.Sel.Name}
}
return nil
}
// text is a comment as prose: the comment markers gone, lines within a
// paragraph joined, paragraphs kept apart.
func text(g *ast.CommentGroup) string {
if g == nil {
return ""
}
var paras []string
var cur []string
for _, line := range strings.Split(g.Text(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
if len(cur) > 0 {
paras = append(paras, strings.Join(cur, " "))
cur = nil
}
continue
}
cur = append(cur, line)
}
if len(cur) > 0 {
paras = append(paras, strings.Join(cur, " "))
}
return strings.Join(paras, "\n\n")
}
// --- an object that keeps its keys in order ---
type kv struct {
k string
v any
}
// object is a JSON object written in the order its keys were set, so the
// document reads in the order it was meant to — openapi, info, paths — and
// a struct's properties in the order its fields are declared.
type object []kv
func obj(pairs ...any) object {
var o object
for i := 0; i+1 < len(pairs); i += 2 {
o.set(pairs[i].(string), pairs[i+1])
}
return o
}
func (o *object) set(k string, v any) {
for i := range *o {
if (*o)[i].k == k {
(*o)[i].v = v
return
}
}
*o = append(*o, kv{k, v})
}
func (o object) get(k string) (any, bool) {
for _, p := range o {
if p.k == k {
return p.v, true
}
}
return nil, false
}
// MarshalJSON writes the keys in order.
func (o object) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteByte('{')
for i, p := range o {
if i > 0 {
b.WriteByte(',')
}
k, err := marshal(p.k)
if err != nil {
return nil, err
}
b.Write(k)
b.WriteByte(':')
v, err := marshal(p.v)
if err != nil {
return nil, err
}
b.Write(v)
}
b.WriteByte('}')
return b.Bytes(), nil
}
// marshal is json.Marshal without escaping <, > and &, which are common in
// the descriptions and harmless in a document nobody puts in a page.
func marshal(v any) ([]byte, error) {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(b.Bytes(), "\n"), nil
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}