docs(api): document per-node usefulness metrics in OpenAPI (#1769)

Documented Node schema (the four #672 usefulness axes + composite + A-F
grade + relay fields) and response schemas on the node endpoints.
Documentation-only; no behaviour change. Pairs with #1762 (documents the
metrics it adds).

Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-06-27 15:01:05 -07:00
1 parent 1adb0116b2
commit 17654dd090
2 files changed
+342 -32

No files matched your search

+176 -32
View File
@@ -12,11 +12,16 @@ import (
// routeMeta holds metadata for a single API route.
type routeMeta struct {
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Tag string `json:"tag"`
Auth bool `json:"auth,omitempty"`
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Tag string `json:"tag"`
Auth bool `json:"auth,omitempty"`
QueryParams []paramMeta `json:"queryParams,omitempty"`
// Response, when non-nil, is the OpenAPI schema object for the 200
// application/json response body. Routes without it fall back to the
// generic {"type":"object"} placeholder. Use schemaRef(...) to point
// at a named entry in components/schemas (see componentSchemas).
Response map[string]interface{} `json:"-"`
}
type paramMeta struct {
@@ -39,14 +44,14 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/config/geo-filter": {Summary: "Get geo-filter configuration", Tag: "config"},
// Admin / system
"GET /api/health": {Summary: "Health check", Description: "Returns server health, uptime, and memory stats.", Tag: "admin"},
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
"GET /api/health": {Summary: "Health check", Description: "Returns server health, uptime, and memory stats.", Tag: "admin"},
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
"GET /api/mqtt/status": {Summary: "MQTT source status", Description: "Returns per-MQTT-source connection state and counters (lastConnectUnix, lastPacketUnix, packetsTotal, etc.). Broker URL passwords are masked. Sourced from the ingestor stats file; empty list when unavailable. (#1043)", Tag: "admin"},
"POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true},
// "POST /api/admin/prune" removed in #1283 (ingestor owns prune).
"GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true},
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true},
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true},
// Packets
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
@@ -70,42 +75,43 @@ func routeDescriptions() map[string]routeMeta {
"POST /api/decode": {Summary: "Decode a raw packet", Description: "Decodes a hex-encoded packet without storing it.", Tag: "packets"},
// Nodes
"GET /api/nodes": {Summary: "List nodes", Description: "Returns all known mesh nodes with status and metadata.", Tag: "nodes",
"GET /api/nodes": {Summary: "List nodes", Description: "Returns all known mesh nodes with status and metadata. Repeater/room rows carry the issue #672 usefulness metrics (traffic_share_score, bridge_score, coverage_score, redundancy_score), the composite usefulness_score + usefulness_grade, and relay-activity counters. See the Node schema.", Tag: "nodes",
Response: schemaRef("NodeListResponse"),
QueryParams: []paramMeta{
{Name: "role", Description: "Filter by node role", Type: "string"},
{Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"},
}},
"GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}},
"GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"},
"GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"},
"GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key.", Tag: "nodes"},
"GET /api/nodes/{pubkey}/health": {Summary: "Get node health", Tag: "nodes"},
"GET /api/nodes/{pubkey}/paths": {Summary: "Get node routing paths", Tag: "nodes"},
"GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}},
"GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"},
"GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"},
"GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")},
"GET /api/nodes/{pubkey}/health": {Summary: "Get node health", Tag: "nodes"},
"GET /api/nodes/{pubkey}/paths": {Summary: "Get node routing paths", Tag: "nodes"},
"GET /api/nodes/{pubkey}/analytics": {Summary: "Get node analytics", Description: "Per-node packet counts, timing, and RF stats.", Tag: "nodes"},
"GET /api/nodes/{pubkey}/neighbors": {Summary: "Get node neighbors", Description: "Returns neighbor nodes with affinity scores.", Tag: "nodes"},
"GET /api/nodes/{pubkey}/neighbors": {Summary: "Get node neighbors", Description: "Returns the queried node's first-hop neighbors with affinity scores and observation metadata (count, SNR, distance, observers). Ambiguous edges carry candidate pubkeys.", Tag: "nodes", Response: schemaRef("NodeNeighborsResponse")},
// Analytics
"GET /api/analytics/rf": {Summary: "RF analytics", Description: "SNR/RSSI distributions and statistics.", Tag: "analytics"},
"GET /api/analytics/topology": {Summary: "Network topology", Description: "Hop-count distribution and route analysis.", Tag: "analytics"},
"GET /api/analytics/channels": {Summary: "Channel analytics", Description: "Message counts and activity per channel.", Tag: "analytics"},
"GET /api/analytics/distance": {Summary: "Distance analytics", Description: "Geographic distance calculations between nodes.", Tag: "analytics"},
"GET /api/analytics/hash-sizes": {Summary: "Hash size analysis", Description: "Distribution of hash prefix sizes across the network.", Tag: "analytics"},
"GET /api/analytics/hash-collisions": {Summary: "Hash collision detection", Description: "Identifies nodes sharing hash prefixes.", Tag: "analytics"},
"GET /api/analytics/subpaths": {Summary: "Subpath analysis", Description: "Common routing subpaths through the mesh.", Tag: "analytics"},
"GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"},
"GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"},
"GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"},
"GET /api/analytics/rf": {Summary: "RF analytics", Description: "SNR/RSSI distributions and statistics.", Tag: "analytics"},
"GET /api/analytics/topology": {Summary: "Network topology", Description: "Hop-count distribution and route analysis.", Tag: "analytics"},
"GET /api/analytics/channels": {Summary: "Channel analytics", Description: "Message counts and activity per channel.", Tag: "analytics"},
"GET /api/analytics/distance": {Summary: "Distance analytics", Description: "Geographic distance calculations between nodes.", Tag: "analytics"},
"GET /api/analytics/hash-sizes": {Summary: "Hash size analysis", Description: "Distribution of hash prefix sizes across the network.", Tag: "analytics"},
"GET /api/analytics/hash-collisions": {Summary: "Hash collision detection", Description: "Identifies nodes sharing hash prefixes.", Tag: "analytics"},
"GET /api/analytics/subpaths": {Summary: "Subpath analysis", Description: "Common routing subpaths through the mesh.", Tag: "analytics"},
"GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"},
"GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"},
"GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"},
// Channels
"GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts.", Tag: "channels"},
"GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"},
// Observers
"GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
"GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
"GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways.", Tag: "observers"},
"GET /api/observers/{id}": {Summary: "Get observer detail", Tag: "observers"},
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"},
// Misc
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
@@ -115,6 +121,136 @@ func routeDescriptions() map[string]routeMeta {
}
}
// schemaRef returns an OpenAPI $ref pointing at a named component schema.
func schemaRef(name string) map[string]interface{} {
return map[string]interface{}{"$ref": "#/components/schemas/" + name}
}
// componentSchemas returns the reusable OpenAPI schemas surfaced under
// components/schemas. The Node schema documents the per-node usefulness
// metrics (issue #672) that the /api/nodes handlers attach to repeater/room
// rows — previously these were set on the wire but undocumented (issue
// #672 / E). Score axes are bounded [0,1]; usefulness_score is the weighted
// composite and usefulness_grade its A–F letter.
func componentSchemas() map[string]interface{} {
score01 := func(desc string) map[string]interface{} {
// "double" matches the Go float64 wire type (some linters flag "float").
return map[string]interface{}{
"type": "number", "format": "double", "minimum": 0, "maximum": 1,
"description": desc,
}
}
str := func(desc string) map[string]interface{} {
m := map[string]interface{}{"type": "string"}
if desc != "" {
m["description"] = desc
}
return m
}
return map[string]interface{}{
"Node": map[string]interface{}{
"type": "object",
// additionalProperties:true — the node object carries more fields
// than documented here (e.g. foreign, default_scope, hash-size and
// multi-byte enrichment); only the stable + #672 fields are spelled
// out. The #672 usefulness fields are emitted only by a server that
// has shipped issue #672 (PR #1762); on an older server they are
// simply absent.
"additionalProperties": true,
"description": "A mesh node. Repeater and room nodes additionally carry the issue #672 usefulness metrics and relay-activity fields below; those fields are absent on other roles. NOTE: coverage_score, redundancy_score and usefulness_grade ship only with the #672 4-axis scorer (PR #1762) and are absent on every build without it; until that lands usefulness_score is aliased to traffic_share_score. Only traffic_share_score and bridge_score ship today.",
"properties": map[string]interface{}{
"public_key": str("Node public key (hex)."),
"name": str("Node display name (most recent advert name)."),
"role": str("Node role (e.g. repeater, room, client, sensor)."),
"lat": map[string]interface{}{"type": "number", "nullable": true},
"lon": map[string]interface{}{"type": "number", "nullable": true},
"last_seen": str("RFC3339 timestamp of the most recent observation."),
"first_seen": str("RFC3339 timestamp of the first observation."),
"advert_count": map[string]interface{}{"type": "integer"},
"battery_mv": map[string]interface{}{"type": "integer", "nullable": true},
"temperature_c": map[string]interface{}{"type": "number", "nullable": true},
"relay_active": map[string]interface{}{"type": "boolean", "description": "Repeater/room only: relayed traffic within the active window."},
"relay_count_1h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last hour."},
"relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last 24 hours."},
"last_relayed": str("Repeater/room only: RFC3339 time this node last appeared as a relay hop."),
"relay_window_hours": map[string]interface{}{"type": "integer", "description": "Repeater/room only, /api/nodes/{pubkey} detail endpoint only: width (hours) of the relay-activity window the relay_count_* values cover."},
"traffic_share_score": score01("#672 Traffic axis: share of non-advert traffic relayed through this repeater. Repeater/room only."),
"bridge_score": score01("#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only."),
"coverage_score": score01("#672 Coverage axis: normalized harmonic reach centrality (how much of the mesh the node can reach). Repeater/room only."),
"redundancy_score": score01("#672 Redundancy axis: normalized articulation-point criticality — 1 means removing the node fragments the mesh, 0 means alternate paths exist. Repeater/room only."),
"usefulness_score": score01("#672 composite usefulness = 0.30·bridge + 0.25·coverage + 0.25·redundancy + 0.20·traffic. Until the 4-axis scorer ships (PR #1762) this is aliased to traffic_share_score. Repeater/room only."),
"usefulness_grade": map[string]interface{}{
"type": "string", "enum": []string{"A", "B", "C", "D", "F"},
"description": "Letter grade derived from usefulness_score. Repeater/room only.",
},
},
},
"NodeListResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"nodes": map[string]interface{}{"type": "array", "items": schemaRef("Node")},
"total": map[string]interface{}{"type": "integer", "description": "Total nodes matching the query after filtering."},
"counts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Per-role node counts."},
},
},
"NodeDetailResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"node": schemaRef("Node"),
"recentAdverts": map[string]interface{}{"type": "array", "items": schemaRef("NodeAdvert"), "description": "Up to 20 most recent transmissions from this node (newest first)."},
},
},
"NodeAdvert": map[string]interface{}{
"type": "object",
"description": "A recent transmission/advert from a node (the /api/packets transmission shape). Only the commonly-used fields are documented.",
"additionalProperties": true,
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer"},
"hash": str("Transmission content hash."),
"payload_type": map[string]interface{}{"type": "integer", "description": "MeshCore payload type."},
"first_seen": str("RFC3339 time the transmission was first observed."),
"from_pubkey": str("Originating node public key."),
},
},
"CandidateEntry": map[string]interface{}{
"type": "object",
"description": "A candidate pubkey offered when a neighbor edge is ambiguous.",
"properties": map[string]interface{}{
"pubkey": str("Candidate node public key (hex)."), "name": str("Candidate node display name."), "role": str("Candidate node role (e.g. repeater, room)."),
},
},
"NeighborEntry": map[string]interface{}{
"type": "object",
"description": "One neighbor of the queried node, with affinity score and observation metadata.",
"properties": map[string]interface{}{
"pubkey": map[string]interface{}{"type": "string", "nullable": true, "description": "Resolved neighbor public key, or null when only a hop prefix is known."},
"prefix": str("Raw hop hash prefix that established this edge."),
"name": map[string]interface{}{"type": "string", "nullable": true},
"role": map[string]interface{}{"type": "string", "nullable": true},
"count": map[string]interface{}{"type": "integer", "description": "Total observations supporting this neighborship."},
"score": score01("Affinity score: count saturation × recency decay × observer-diversity confidence."),
"counts_by_mode": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "#1638: observation counts keyed by hash-prefix mode in bytes (1/2/3; 0 = legacy/unknown)."},
"first_seen": str(""),
"last_seen": str(""),
"avg_snr": map[string]interface{}{"type": "number", "nullable": true},
"distance_km": map[string]interface{}{"type": "number", "nullable": true},
"observers": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"ambiguous": map[string]interface{}{"type": "boolean"},
"unresolved": map[string]interface{}{"type": "boolean"},
"candidates": map[string]interface{}{"type": "array", "items": schemaRef("CandidateEntry")},
},
},
"NodeNeighborsResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"node": str("The queried node's public key."),
"neighbors": map[string]interface{}{"type": "array", "items": schemaRef("NeighborEntry")},
"total_observations": map[string]interface{}{"type": "integer"},
},
},
}
}
// buildOpenAPISpec constructs an OpenAPI 3.0 spec by walking the mux router.
func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} {
descriptions := routeDescriptions()
@@ -168,6 +304,13 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
// Convert mux path params {name} to OpenAPI {name} (same format, convenient)
openAPIPath := ri.path
// Documented routes can declare a concrete 200 response schema;
// everything else falls back to the generic object placeholder.
respSchema := map[string]interface{}{"type": "object"}
if hasMeta && meta.Response != nil {
respSchema = meta.Response
}
// Build operation
op := map[string]interface{}{
"summary": func() string {
@@ -181,7 +324,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
"description": "Success",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"type": "object"},
"schema": respSchema,
},
},
},
@@ -285,6 +428,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
"name": "X-API-Key",
},
},
"schemas": componentSchemas(),
},
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fetchSpec hits /api/spec and returns the decoded OpenAPI document.
func fetchSpec(t *testing.T) map[string]interface{} {
t.Helper()
_, r := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/spec", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("/api/spec: expected 200, got %d", w.Code)
}
var spec map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil {
t.Fatalf("invalid spec JSON: %v", err)
}
return spec
}
func asMap(t *testing.T, v interface{}, what string) map[string]interface{} {
t.Helper()
m, ok := v.(map[string]interface{})
if !ok {
t.Fatalf("expected object for %s, got %T", what, v)
}
return m
}
// TestOpenAPINodeSchema_Metrics pins issue #672 / E: the per-node usefulness
// metrics are documented in components/schemas.Node with bounded score ranges
// and an A–F grade enum — not just set on the wire.
func TestOpenAPINodeSchema_Metrics(t *testing.T) {
spec := fetchSpec(t)
components := asMap(t, spec["components"], "components")
schemas := asMap(t, components["schemas"], "components.schemas")
node, ok := schemas["Node"]
if !ok {
t.Fatal("components.schemas.Node missing")
}
props := asMap(t, asMap(t, node, "Node")["properties"], "Node.properties")
// Each #672 score axis must be present, numeric, and bounded [0,1].
for _, field := range []string{"traffic_share_score", "bridge_score", "coverage_score", "redundancy_score", "usefulness_score"} {
p, ok := props[field]
if !ok {
t.Errorf("Node.properties.%s missing", field)
continue
}
pm := asMap(t, p, field)
if pm["type"] != "number" {
t.Errorf("%s: want type number, got %v", field, pm["type"])
}
if pm["minimum"] != float64(0) || pm["maximum"] != float64(1) {
t.Errorf("%s: want bounds [0,1], got [%v,%v]", field, pm["minimum"], pm["maximum"])
}
if d, _ := pm["description"].(string); !strings.Contains(d, "#672") {
t.Errorf("%s: description should cite #672, got %q", field, d)
}
}
// usefulness_grade is an A–F enum.
grade := asMap(t, props["usefulness_grade"], "usefulness_grade")
enum, ok := grade["enum"].([]interface{})
if !ok || len(enum) != 5 || enum[0] != "A" || enum[4] != "F" {
t.Errorf("usefulness_grade enum should be [A,B,C,D,F], got %v", grade["enum"])
}
// Relay-activity fields are documented too.
for _, field := range []string{"relay_active", "relay_count_1h", "relay_count_24h", "last_relayed"} {
if _, ok := props[field]; !ok {
t.Errorf("Node.properties.%s missing", field)
}
}
}
// TestOpenAPINodeEndpoints_ReferenceSchemas verifies the three node endpoints
// advertise concrete response schemas (not the bare {"type":"object"}
// placeholder) that resolve to the Node schema.
func TestOpenAPINodeEndpoints_ReferenceSchemas(t *testing.T) {
spec := fetchSpec(t)
paths := asMap(t, spec["paths"], "paths")
respRef := func(path string) string {
p, ok := paths[path]
if !ok {
t.Fatalf("path %s missing", path)
}
get := asMap(t, asMap(t, p, path)["get"], path+".get")
resp200 := asMap(t, asMap(t, get["responses"], path+".responses")["200"], path+".200")
appjson := asMap(t, asMap(t, resp200["content"], path+".content")["application/json"], path+".application/json")
schema := asMap(t, appjson["schema"], path+".schema")
ref, _ := schema["$ref"].(string)
return ref
}
cases := map[string]string{
"/api/nodes": "NodeListResponse",
"/api/nodes/{pubkey}": "NodeDetailResponse",
"/api/nodes/{pubkey}/neighbors": "NodeNeighborsResponse",
}
for path, want := range cases {
ref := respRef(path)
if !strings.HasSuffix(ref, "/"+want) {
t.Errorf("%s: 200 schema should $ref %s, got %q", path, want, ref)
}
}
// The list wrapper's items must resolve to the Node schema.
schemas := asMap(t, asMap(t, spec["components"], "components")["schemas"], "schemas")
list := asMap(t, schemas["NodeListResponse"], "NodeListResponse")
nodes := asMap(t, asMap(t, list["properties"], "props")["nodes"], "nodes")
items := asMap(t, nodes["items"], "items")
if ref, _ := items["$ref"].(string); !strings.HasSuffix(ref, "/Node") {
t.Errorf("NodeListResponse.nodes.items should $ref Node, got %q", ref)
}
}
// TestOpenAPISchema_FullCoverage pins the #1769-review fixes: the schemas must
// document every field the handlers actually emit — recentAdverts on node
// detail and counts_by_mode on neighbor entries — and the Node schema must
// allow the additional undocumented fields it carries.
func TestOpenAPISchema_FullCoverage(t *testing.T) {
spec := fetchSpec(t)
schemas := asMap(t, asMap(t, spec["components"], "components")["schemas"], "schemas")
// node detail also returns recentAdverts (array of NodeAdvert).
detail := asMap(t, schemas["NodeDetailResponse"], "NodeDetailResponse")
dprops := asMap(t, detail["properties"], "NodeDetailResponse.properties")
ra, ok := dprops["recentAdverts"]
if !ok {
t.Fatal("NodeDetailResponse.recentAdverts missing (handler emits it)")
}
raItems := asMap(t, asMap(t, ra, "recentAdverts")["items"], "recentAdverts.items")
if ref, _ := raItems["$ref"].(string); !strings.HasSuffix(ref, "/NodeAdvert") {
t.Errorf("recentAdverts.items should $ref NodeAdvert, got %q", ref)
}
if _, ok := schemas["NodeAdvert"]; !ok {
t.Error("components.schemas.NodeAdvert missing")
}
// neighbor entries also carry counts_by_mode (#1638).
ne := asMap(t, schemas["NeighborEntry"], "NeighborEntry")
nprops := asMap(t, ne["properties"], "NeighborEntry.properties")
cbm, ok := nprops["counts_by_mode"]
if !ok {
t.Fatal("NeighborEntry.counts_by_mode missing (struct has CountsByMode)")
}
if asMap(t, cbm, "counts_by_mode")["type"] != "object" {
t.Error("counts_by_mode should be type object with integer additionalProperties")
}
// Node tolerates the fields it emits but does not spell out.
node := asMap(t, schemas["Node"], "Node")
if node["additionalProperties"] != true {
t.Errorf("Node should set additionalProperties:true, got %v", node["additionalProperties"])
}
}