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>
673 lines
18 KiB
Go
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
|
|
}
|