/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>
201 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|