From 17654dd09087fb4ad0926509178eb68b3d6dfe5d Mon Sep 17 00:00:00 2001 From: "Michael J. Arcan" Date: Sun, 28 Jun 2026 00:01:05 +0200 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/server/openapi.go | 208 +++++++++++++++++++++---- cmd/server/openapi_node_schema_test.go | 166 ++++++++++++++++++++ 2 files changed, 342 insertions(+), 32 deletions(-) create mode 100644 cmd/server/openapi_node_schema_test.go diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index a6b65af3..7f337944 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -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-.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-.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(), }, } diff --git a/cmd/server/openapi_node_schema_test.go b/cmd/server/openapi_node_schema_test.go new file mode 100644 index 00000000..40b9270e --- /dev/null +++ b/cmd/server/openapi_node_schema_test.go @@ -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"]) + } +}