Files
llmgw/internal/docgen/openapi/openapi_test.go
Ilker ManapandClaude Opus 5.5 fd3c7bda32
CI / test (push) Failing after 6m12s
The client API described in OpenAPI, and both APIs in Swagger UI
/v1/openapi.json is the API clients use — OpenAI's endpoints, Anthropic's,
and what llmgw adds — in OpenAPI 3.1, generated from internal/clientapi's
route table by the generator the admin API's document comes from, now
told each document's title, server, token and error. A test reads the
router's mux.HandleFunc calls and refuses a route described and not
mounted, or mounted and not described. Asked for before there is a key,
so it needs none.

/docs/api shows it in Swagger UI 5.33.1 (Apache 2.0, served from the
gateway as the page's policy requires): Authorize takes a key, Try it out
sends to this gateway. Staff are offered the admin API's document too,
from /docs/api/admin.json, which nobody else is given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:22:48 +02:00

201 lines
6.6 KiB
Go

package openapi
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
"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")
}
}
func TestTheClientAPIDocumentIsNotStaleAndDescribesWhatClientsSend(t *testing.T) {
want, err := GenerateDoc(repoRoot(t), Client, clientapi.Routes, clientapi.PathParams)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "client-api.openapi.json"))
if err != nil || string(got) != string(want) {
t.Fatal("docs/client-api.openapi.json no longer matches the client API's routes and types.\n" +
"Run: go generate ./internal/docgen")
}
var doc map[string]any
if err := json.Unmarshal(want, &doc); err != nil {
t.Fatal(err)
}
chat := at(t, doc, "paths", "/v1/chat/completions", "post")
if at(t, chat, "requestBody", "content", "application/json", "schema", "$ref") != "#/components/schemas/ChatCompletionRequest" {
t.Error("chat's body is not its request type")
}
if _, ok := at(t, chat, "responses", "200", "content").(map[string]any)["text/event-stream"]; !ok {
t.Error("chat does not say it may stream")
}
if _, ok := at(t, doc, "paths", "/v1/files", "post", "requestBody", "content").(map[string]any)["multipart/form-data"]; !ok {
t.Error("an upload is not a form")
}
if at(t, doc, "paths", "/v1/3d/generations", "post", "responses", "202", "description") == nil {
t.Error("a 3D job is not answered 202")
}
if _, ok := at(t, doc, "components", "securitySchemes").(map[string]any)["apiKey"]; !ok {
t.Error("no API key scheme")
}
// The admin API's actor header is the admin API's.
if _, ok := at(t, doc, "components").(map[string]any)["parameters"]; ok {
t.Error("the client API takes the admin API's X-Llmgw-Actor")
}
}