Type the REST API's responses

Every tool now returns a Go struct (apitypes.go) instead of a map, and
typeschema.go derives the OpenAPI response schemas from those types by
reflection (json, doc and enum tags), so the document cannot drift from
what is sent. Text results are documented as text/plain, settings in
answers use the typed settings schema, and edits answer with EditResponse.

TestResponsesMatchTheirSchemas checks real responses against the
generated schemas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-05 08:10:59 +01:00
co-authored by Claude Sonnet 5.5
parent e3209569bd
commit 98b0ebc130
8 changed files with 436 additions and 79 deletions
+142
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -282,3 +283,144 @@ func TestSpecExplainsETags(t *testing.T) {
t.Errorf("GET settings does not document its ETag header")
}
}
// strs reads a string or a list of strings of a decoded schema.
func strs(v any) []string {
switch x := v.(type) {
case string:
return []string{x}
case []any:
var out []string
for _, e := range x {
out = append(out, fmt.Sprint(e))
}
return out
}
return nil
}
// conforms checks a decoded JSON value against a schema of the OpenAPI
// document (the subset typeschema.go emits), resolving $ref in spec.
func conforms(v any, s map[string]any, spec map[string]any, path string) []string {
if ref, ok := s["$ref"].(string); ok {
name := strings.TrimPrefix(ref, "#/components/schemas/")
def, _ := spec["components"].(map[string]any)["schemas"].(map[string]any)[name].(map[string]any)
if def == nil {
return []string{path + ": unknown schema " + name}
}
return conforms(v, def, spec, path)
}
if branches, ok := s["anyOf"].([]any); ok {
for _, b := range branches {
if len(conforms(v, b.(map[string]any), spec, path)) == 0 {
return nil
}
}
return []string{path + ": matches none of the alternatives"}
}
types := strs(s["type"])
if len(types) == 0 {
return nil // any
}
kind := "null"
switch v.(type) {
case bool:
kind = "boolean"
case string:
kind = "string"
case float64:
kind = "number"
case []any:
kind = "array"
case map[string]any:
kind = "object"
}
if f, ok := v.(float64); ok && f == float64(int64(f)) && slices.Contains(types, "integer") {
kind = "integer"
}
if !slices.Contains(types, kind) {
return []string{path + ": is " + kind + ", the schema says " + strings.Join(types, " or ")}
}
var errs []string
switch x := v.(type) {
case string:
if s["format"] == "date-time" {
if _, err := time.Parse(time.RFC3339Nano, x); err != nil {
errs = append(errs, path+": not a date-time: "+x)
}
}
if enum := strs(s["enum"]); enum != nil && !slices.Contains(enum, x) {
errs = append(errs, path+": "+x+" is not in the enum")
}
case []any:
items, _ := s["items"].(map[string]any)
for i, e := range x {
errs = append(errs, conforms(e, items, spec, fmt.Sprintf("%s[%d]", path, i))...)
}
case map[string]any:
props, _ := s["properties"].(map[string]any)
{
for _, k := range strs(s["required"]) {
if _, ok := x[k]; !ok {
errs = append(errs, path+": "+k+" is missing")
}
}
}
for k, c := range x {
if p, ok := props[k].(map[string]any); ok {
errs = append(errs, conforms(c, p, spec, path+"/"+k)...)
} else if add, ok := s["additionalProperties"].(map[string]any); ok {
errs = append(errs, conforms(c, add, spec, path+"/"+k)...)
} else if props != nil {
errs = append(errs, path+": "+k+" is not in the schema")
}
}
}
return errs
}
// What the API answers is what the OpenAPI document says it answers.
func TestResponsesMatchTheirSchemas(t *testing.T) {
a, sys := newTestApp(t)
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
os.WriteFile(sys.confirmedPath(), data, 0o600)
}
h := a.routes()
_, tok, _ := a.tokens.Create("changer", "change")
// Through JSON, as a client sees the document.
var spec map[string]any
data, _ := json.Marshal(openAPISpec("1.2.3", nil, "router.example"))
json.Unmarshal(data, &spec)
check := func(method, path, body string, headers map[string]string) {
t.Helper()
rec := rest(h, tok, method, path, body, headers)
if rec.Code != 200 {
t.Errorf("%s %s: %d %s", method, path, rec.Code, rec.Body)
return
}
op := spec["paths"].(map[string]any)[path].(map[string]any)[strings.ToLower(method)].(map[string]any)
content := op["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
js, ok := content["application/json"].(map[string]any)
if !ok {
if _, text := content["text/plain"]; !text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
t.Errorf("%s %s: documented %v, sent %s", method, path, content, rec.Header().Get("Content-Type"))
}
return
}
var v any
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Errorf("%s %s: %v", method, path, err)
return
}
if errs := conforms(v, js["schema"].(map[string]any), spec, ""); len(errs) > 0 {
t.Errorf("%s %s does not match its schema: %v\n%s", method, path, errs, rec.Body)
}
}
for _, p := range []string{"/api/v1/status", "/api/v1/settings", "/api/v1/interfaces", "/api/v1/dhcp-leases", "/api/v1/update", "/api/v1/apply-log"} {
check("GET", p, "", nil)
}
check("GET", "/api/v1/changes", "", nil)
check("DELETE", "/api/v1/changes", "", nil)
rec := rest(h, tok, "GET", "/api/v1/settings", "", nil)
check("PATCH", "/api/v1/settings", `{"system":{"hostName":"gw"}}`, map[string]string{"If-Match": rec.Header().Get("ETag")})
}