llmgw_org, llmgw_team and llmgw_project rename and move in place; llmgw_key holds the key in a sensitive secret and is made again when revoked or expired outside Terraform; llmgw_membership puts a person in the tree. Tried with OpenTofu against the fake admin API, drift and import included. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
217 lines
7.2 KiB
Go
217 lines
7.2 KiB
Go
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The gateway's OpenAPI document is generated from its route table and the
|
|
// Go types its handlers use, and a test there keeps it current. Checking
|
|
// this client against it is what keeps the two modules in step without the
|
|
// provider importing the gateway.
|
|
|
|
type document struct {
|
|
Paths map[string]map[string]operation `json:"paths"`
|
|
Components struct {
|
|
Schemas map[string]schemaDoc `json:"schemas"`
|
|
} `json:"components"`
|
|
}
|
|
|
|
type operation struct {
|
|
RequestBody *struct {
|
|
Content map[string]struct {
|
|
Schema schemaDoc `json:"schema"`
|
|
} `json:"content"`
|
|
} `json:"requestBody"`
|
|
Responses map[string]struct {
|
|
Content map[string]struct {
|
|
Schema schemaDoc `json:"schema"`
|
|
} `json:"content"`
|
|
} `json:"responses"`
|
|
}
|
|
|
|
type schemaDoc struct {
|
|
Ref string `json:"$ref"`
|
|
Type string `json:"type"`
|
|
Items *schemaDoc `json:"items"`
|
|
Properties map[string]schemaDoc `json:"properties"`
|
|
}
|
|
|
|
func (s schemaDoc) name() string { return strings.TrimPrefix(s.Ref, "#/components/schemas/") }
|
|
|
|
func load(t *testing.T) document {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("cannot locate this test")
|
|
}
|
|
p := filepath.Join(filepath.Dir(file), "..", "..", "..", "docs", "admin-api.openapi.json")
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatalf("the gateway's OpenAPI document: %v", err)
|
|
}
|
|
var d document
|
|
if err := json.Unmarshal(b, &d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (d document) op(t *testing.T, endpoint string) (operation, bool) {
|
|
method, p, _ := strings.Cut(endpoint, " ")
|
|
op, ok := d.Paths[p][strings.ToLower(method)]
|
|
return op, ok
|
|
}
|
|
|
|
func TestEveryEndpointTheClientCallsIsInTheDocument(t *testing.T) {
|
|
d := load(t)
|
|
for _, e := range Endpoints {
|
|
if _, ok := d.op(t, e); !ok {
|
|
t.Errorf("the client calls %s, which the admin API does not serve", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// bodies is each endpoint's request and answer: the client's type and the
|
|
// schema the document names for it. Empty is none, or one the client
|
|
// ignores.
|
|
var bodies = []struct {
|
|
endpoint string
|
|
request any
|
|
requestSchema string
|
|
answer any
|
|
answerSchema string
|
|
}{
|
|
{"GET /api/v1/models", nil, "", ModelView{}, "ModelView"},
|
|
{"POST /api/v1/models", Model{}, "catalog.Model", nil, ""},
|
|
{"GET /api/v1/models/{name}", nil, "", Model{}, "catalog.Model"},
|
|
{"POST /api/v1/models/{name}/scale", ScaleRequest{}, "ScaleRequest", nil, ""},
|
|
{"POST /api/v1/models/{name}/class", ClassRequest{}, "ClassRequest", nil, ""},
|
|
{"POST /api/v1/models/{name}/owner", OwnerRequest{}, "OwnerRequest", OwnerView{}, "OwnerView"},
|
|
{"POST /api/v1/models/{name}/adapters", AdaptersRequest{}, "AdaptersRequest", nil, ""},
|
|
{"GET /api/v1/nodes", nil, "", NodeView{}, "NodeView"},
|
|
{"GET /api/v1/quotas", nil, "", QuotaView{}, "QuotaView"},
|
|
{"PUT /api/v1/quotas", QuotaRequest{}, "QuotaRequest", QuotaSet{}, "QuotaSet"},
|
|
{"GET /api/v1/guard", nil, "", GuardView{}, "GuardView"},
|
|
{"POST /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", Attachment{}, "guard.Attachment"},
|
|
{"DELETE /api/v1/guard/attachments", GuardAttachmentRequest{}, "GuardAttachmentRequest", nil, ""},
|
|
{"GET /api/v1/orgs", nil, "", OrgView{}, "OrgView"},
|
|
{"POST /api/v1/orgs", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
|
|
{"PATCH /api/v1/orgs/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
|
|
{"POST /api/v1/teams", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
|
|
{"PATCH /api/v1/teams/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
|
|
{"POST /api/v1/projects", TenancyCreateRequest{}, "TenancyCreateRequest", TenancyItem{}, "TenancyItem"},
|
|
{"PATCH /api/v1/projects/{id}", TenancyChangeRequest{}, "TenancyChangeRequest", TenancyItem{}, "TenancyItem"},
|
|
{"POST /api/v1/keys", KeyCreateRequest{}, "KeyCreateRequest", KeyCreated{}, "KeyCreated"},
|
|
{"GET /api/v1/keys/{id}", nil, "", KeyView{}, "KeyView"},
|
|
{"GET /api/v1/users/{id}", nil, "", UserView{}, "UserView"},
|
|
{"POST /api/v1/users/{id}/memberships", MembershipRequest{}, "MembershipRequest", UserView{}, "UserView"},
|
|
{"DELETE /api/v1/users/{id}/memberships", MembershipRequest{}, "MembershipRequest", UserView{}, "UserView"},
|
|
}
|
|
|
|
// nested are the client's types inside those bodies, and their schemas.
|
|
var nested = map[reflect.Type]string{
|
|
reflect.TypeOf(Profile{}): "catalog.Profile",
|
|
reflect.TypeOf(Scaling{}): "catalog.Scaling",
|
|
reflect.TypeOf(External{}): "catalog.External",
|
|
reflect.TypeOf(Adapter{}): "catalog.Adapter",
|
|
reflect.TypeOf(Capabilities{}): "catalog.Capabilities",
|
|
reflect.TypeOf(Resources{}): "store.Resources",
|
|
reflect.TypeOf(Attachment{}): "GuardAttachmentView",
|
|
reflect.TypeOf(TeamView{}): "TeamView",
|
|
reflect.TypeOf(ProjectView{}): "ProjectView",
|
|
reflect.TypeOf(MembershipView{}): "MembershipView",
|
|
}
|
|
|
|
func TestEveryBodyTheClientSendsAndReadsMatchesTheDocument(t *testing.T) {
|
|
d := load(t)
|
|
for _, b := range bodies {
|
|
op, ok := d.op(t, b.endpoint)
|
|
if !ok {
|
|
t.Errorf("%s is not in the document", b.endpoint)
|
|
continue
|
|
}
|
|
if b.request != nil {
|
|
if op.RequestBody == nil {
|
|
t.Errorf("%s takes no body in the document", b.endpoint)
|
|
} else if got := op.RequestBody.Content["application/json"].Schema.name(); got != b.requestSchema {
|
|
t.Errorf("%s takes %s, not %s", b.endpoint, got, b.requestSchema)
|
|
}
|
|
checkType(t, d, reflect.TypeOf(b.request), b.requestSchema)
|
|
}
|
|
if b.answer != nil {
|
|
var s schemaDoc
|
|
for code, r := range op.Responses {
|
|
if strings.HasPrefix(code, "2") {
|
|
s = r.Content["application/json"].Schema
|
|
}
|
|
}
|
|
if s.Type == "array" && s.Items != nil {
|
|
s = *s.Items
|
|
}
|
|
if s.name() != b.answerSchema {
|
|
t.Errorf("%s answers with %s, not %s", b.endpoint, s.name(), b.answerSchema)
|
|
}
|
|
checkType(t, d, reflect.TypeOf(b.answer), b.answerSchema)
|
|
}
|
|
}
|
|
for typ, name := range nested {
|
|
checkType(t, d, typ, name)
|
|
}
|
|
}
|
|
|
|
// checkType fails for each of a struct's JSON keys the schema does not have,
|
|
// or has as another kind of value.
|
|
func checkType(t *testing.T, d document, typ reflect.Type, schema string) {
|
|
t.Helper()
|
|
s, ok := d.Components.Schemas[schema]
|
|
if !ok {
|
|
t.Errorf("no schema %s in the document", schema)
|
|
return
|
|
}
|
|
for i := range typ.NumField() {
|
|
f := typ.Field(i)
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if name == "" {
|
|
name = f.Name
|
|
}
|
|
p, ok := s.Properties[name]
|
|
if !ok {
|
|
t.Errorf("%s.%s is %q, which %s does not have", typ.Name(), f.Name, name, schema)
|
|
continue
|
|
}
|
|
if want := kindOf(f.Type); want != "" && p.Type != "" && p.Type != want {
|
|
t.Errorf("%s.%s is a %s here and a %s in %s", typ.Name(), f.Name, want, p.Type, schema)
|
|
}
|
|
}
|
|
}
|
|
|
|
func kindOf(t reflect.Type) string {
|
|
if t == reflect.TypeOf(time.Duration(0)) {
|
|
return "integer"
|
|
}
|
|
switch t.Kind() {
|
|
case reflect.Pointer:
|
|
return kindOf(t.Elem())
|
|
case reflect.String:
|
|
return "string"
|
|
case reflect.Bool:
|
|
return "boolean"
|
|
case reflect.Int, reflect.Int64:
|
|
return "integer"
|
|
case reflect.Float64:
|
|
return "number"
|
|
case reflect.Slice:
|
|
return "array"
|
|
case reflect.Map:
|
|
return "object"
|
|
}
|
|
return ""
|
|
}
|