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

164 lines
4.9 KiB
Go

package openapi
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate this test")
}
return filepath.Join(filepath.Dir(file), "..", "..", "..")
}
func generated(t *testing.T) map[string]any {
t.Helper()
b, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams)
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatalf("the document is not JSON: %v", err)
}
return doc
}
// at walks a decoded document by keys.
func at(t *testing.T, v any, keys ...string) any {
t.Helper()
for _, k := range keys {
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("%s: not an object", strings.Join(keys, "."))
}
v, ok = m[k]
if !ok {
t.Fatalf("no %s (of %s)", k, strings.Join(keys, "."))
}
}
return v
}
func TestTheAdminAPIDocumentIsNotStale(t *testing.T) {
// Like the configuration reference: a description of the API that is not
// regenerated describes some earlier week's API, and the server serves the
// committed copy to every client generated from it.
want, err := Generate(repoRoot(t), adminapi.Routes, adminapi.PathParams)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "admin-api.openapi.json"))
if err != nil {
t.Fatalf("docs/admin-api.openapi.json is missing; run go generate ./internal/docgen: %v", err)
}
if string(got) != string(want) {
t.Error("docs/admin-api.openapi.json no longer matches the admin API's routes and types.\n" +
"Run: go generate ./internal/docgen")
}
}
func TestEveryReferenceInTheDocumentResolves(t *testing.T) {
doc := generated(t)
var walk func(v any)
n := 0
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
if r, ok := x["$ref"].(string); ok {
n++
parts := strings.Split(strings.TrimPrefix(r, "#/"), "/")
at(t, doc, parts...)
}
for _, e := range x {
walk(e)
}
case []any:
for _, e := range x {
walk(e)
}
}
}
walk(doc)
if n < 50 {
t.Errorf("only %d references; the schemas are not being shared", n)
}
}
func TestABodyIsDescribedByItsGoType(t *testing.T) {
doc := generated(t)
scale := at(t, doc, "paths", "/api/v1/models/{name}/scale", "post")
if r := at(t, scale, "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/ScaleRequest" {
t.Errorf("scale's body is %v", r)
}
props := at(t, doc, "components", "schemas", "ScaleRequest", "properties").(map[string]any)
// The tag's name, not the field's.
for _, k := range []string{"min", "max", "idle_timeout"} {
if _, ok := props[k]; !ok {
t.Errorf("ScaleRequest has no %s: %v", k, props)
}
}
// The field's comment is its description, and a duration says its unit.
if d := at(t, props, "max", "description").(string); !strings.Contains(d, "follows Min") {
t.Errorf("max lost its comment: %q", d)
}
if d := at(t, props, "idle_timeout", "description").(string); !strings.Contains(d, "nanoseconds") {
t.Errorf("idle_timeout does not say it is nanoseconds: %q", d)
}
// A type from another package is named with its package.
if r := at(t, doc, "paths", "/api/v1/models", "post", "requestBody", "content", "application/json", "schema", "$ref"); r != "#/components/schemas/catalog.Model" {
t.Errorf("adding a model takes %v", r)
}
}
func TestAnEmbeddedStructsFieldsAreItsOwn(t *testing.T) {
// CapacityView embeds bench.Capacity, and encoding/json writes the
// embedded fields at the top level: so must the schema.
doc := generated(t)
props := at(t, doc, "components", "schemas", "CapacityView", "properties").(map[string]any)
for _, k := range []string{"measured_by", "model", "profile"} {
if _, ok := props[k]; !ok {
t.Errorf("CapacityView has no %s: %v", k, props)
}
}
}
func TestEveryChangeNeedsTheTokenAndNamesItsActor(t *testing.T) {
doc := generated(t)
if s := at(t, doc, "components", "securitySchemes", "adminToken", "scheme"); s != "bearer" {
t.Errorf("scheme %v", s)
}
if len(at(t, doc, "security").([]any)) != 1 {
t.Error("the token is not required document-wide")
}
del := at(t, doc, "paths", "/api/v1/models/{name}", "delete")
found := false
for _, p := range at(t, del, "parameters").([]any) {
if p.(map[string]any)["$ref"] == "#/components/parameters/Actor" {
found = true
}
}
if !found {
t.Error("removing a model does not take X-Llmgw-Actor")
}
if _, ok := at(t, del, "responses").(map[string]any)["401"]; !ok {
t.Error("removing a model does not say it can be refused for the token")
}
}
func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) {
routes := []adminapi.Route{{Method: "GET", Path: "/api/v1/things/{id}", Summary: "x", Response: adminapi.Result{}}}
if _, err := Generate(repoRoot(t), routes, adminapi.PathParams); err == nil {
t.Error("a parameter nobody described was let through")
}
}