mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
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:
2 files changed
+342
-32
No files matched your search
+176
-32
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user