package main import ( "encoding/json" "fmt" "net/http" "sort" "strings" "github.com/gorilla/mux" ) // 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"` 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 { Name string `json:"name"` Description string `json:"description"` Required bool `json:"required,omitempty"` Type string `json:"type"` // "string", "integer", "boolean" } // routeDescriptions returns metadata for all known API routes. // Key format: "METHOD /path/pattern" func routeDescriptions() map[string]routeMeta { return map[string]routeMeta{ // Config "GET /api/config/cache": {Summary: "Get cache configuration", Tag: "config"}, "GET /api/config/client": {Summary: "Get client configuration", Tag: "config"}, "GET /api/config/regions": {Summary: "Get configured regions", Tag: "config"}, "GET /api/config/theme": {Summary: "Get theme configuration", Description: "Returns color maps, CSS variables, and theme defaults.", Tag: "config"}, "GET /api/config/map": {Summary: "Get map configuration", Tag: "config"}, "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/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}, // Packets "GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets", QueryParams: []paramMeta{ {Name: "limit", Description: "Max packets to return", Type: "integer"}, {Name: "offset", Description: "Pagination offset", Type: "integer"}, {Name: "sort", Description: "Sort field", Type: "string"}, {Name: "order", Description: "Sort order (asc/desc)", Type: "string"}, {Name: "type", Description: "Filter by packet type", Type: "string"}, {Name: "observer", Description: "Filter by observer ID", Type: "string"}, {Name: "timeRange", Description: "Time range filter (e.g. 1h, 24h, 7d)", Type: "string"}, {Name: "search", Description: "Full-text search", Type: "string"}, {Name: "groupByHash", Description: "Group duplicate packets by hash", Type: "boolean"}, }}, "POST /api/packets": {Summary: "Ingest a packet", Description: "Submit a raw packet for decoding and storage.", Tag: "packets", Auth: true}, "GET /api/packets/{id}": {Summary: "Get packet detail", Tag: "packets"}, "GET /api/packets/timestamps": {Summary: "Get packet timestamp ranges", Tag: "packets"}, "POST /api/packets/observations": {Summary: "Batch submit observations", Description: "Submit multiple observer sightings for existing packets.", Tag: "packets"}, // Decode "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. 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"}, {Name: "geoFilter", Description: "Overrides the deployment's geo_filter node-list default for this one request: \"1\"/\"true\" excludes nodes outside the configured geo_filter (unless foreign_advert-tagged), \"0\"/\"false\" returns every node regardless. Any other value (including omitting it) uses the deployment default — geo_filter applies to the node list unless config.json sets geoFilterExemptNodeList=true.", Type: "string"}, {Name: "hasScope", Description: "Issue #1862. \"true\" keeps only nodes that have ever transported at least one region-scoped (TRANSPORT_FLOOD/DIRECT) packet; \"false\" keeps only nodes that never have. Backed by the same relay-activity signal as the Scopes tab's \"Repeaters Never Relaying Any Scope\" section — pair with role=repeater to match its exact semantics.", Type: "string"}, {Name: "hashRegion", Description: "Issue #1862. Comma-separated region scope name(s) (e.g. \"eu,be\"; leading \"#\" optional, case-insensitive) — keeps only nodes that have transported at least one of them. Combines with hasScope as AND, not OR.", 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. 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}/hop_analytics": {Summary: "Get node hop-count analytics", Description: "Issue #1812. For each recent transmission that passed through this node as a relay, its hop-count AT THIS NODE — the node's own 0-based index within the packet's resolved relay path, i.e. the number MeshCore firmware compares against flood_max/flood_max_advert/flood_max_unscoped in allowPacketForward. Deliberately not the same number as /analytics' hopDistribution field, which is path length to whichever observer reported the packet (a different, unrelated distance). Only transmissions with a resolved relay path are included.", Tag: "nodes", Response: schemaRef("NodeHopAnalyticsResponse"), QueryParams: []paramMeta{ {Name: "days", Description: "Time window in days, 1-365.", Type: "integer"}, }}, "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/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages normally carry an anonymous session token, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap, each with an AirtimeMs field — LoRa Time-on-Air × distinct relaying repeaters, same formula as the Overview tab's Relay Airtime Share, omitted in DB-only mode), and any senders who explicitly shared their own position (some clients append plaintext \",\" after the token — a deliberate choice by that sender, confirmed empirically, not something CoreScope infers). Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, {Name: "channel", Description: "Channel name to analyze (default #wardriving)", Type: "string"}, }}, "GET /api/analytics/hop-depth": {Summary: "Network-wide hop-depth analytics", Description: "Answers three flood-containment questions in one pass over resolved relay paths, using the same 0-based per-node path-index hop count as /api/nodes/{pubkey}/hop_analytics (issue #1812), not the unrelated observer-distance hopDistribution field: (1) does scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic actually travel fewer hops network-wide than unscoped (plain FLOOD, non-advert) traffic, (2) which repeater/room nodes are relaying unscoped flood traffic that already traveled far (high hops, a stronger containment-problem signal) vs merely locally (low hops), and (3) is that containment trending better or worse over the window (timeSeries). Plain DIRECT traffic never undergoes flood propagation and is excluded throughout. Cached 30s per window.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, }, Response: schemaRef("HopDepthAnalyticsResponse")}, "GET /api/analytics/wardriving/sender-messages": {Summary: "Wardriving sender message drill-down", Description: "Individual #wardriving messages from one sender (drill-down behind Top Senders/Sessions): each message's entry-point path (path[0] first, resolve names via /api/resolve-hops), per-observer SNR/RSSI, and lat/lon when that message carried an explicit shared position. Pass since+until (RFC3339) to scope to one session's exact range; otherwise window covers the sender's whole activity in that period. Capped at 200 messages, most-recent-first. Not cached.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "sender", Description: "Sender display name to look up (required, exact match)", Type: "string"}, {Name: "channel", Description: "Channel name (default #wardriving)", Type: "string"}, {Name: "window", Description: "Time window when since/until aren't given: 1h, 24h (default), or 7d", Type: "string"}, {Name: "since", Description: "RFC3339 start time — overrides window when paired with until", Type: "string"}, {Name: "until", Description: "RFC3339 end time — overrides window when paired with since", Type: "string"}, }}, // 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/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node. `seenViaPackets` cross-references the packet-path-inferred neighbor_edges graph: false means this firmware-confirmed neighbor has never had a resolved packet path between it and the observer, a diagnostic signal (possible coverage gap or packet loss), not itself a fault.", Tag: "observers"}, "GET /api/observers/{id}/neighbors/{pubkey}/metrics": {Summary: "Get SNR history for one observer<->neighbor direct-RF link", Description: "Raw (unaggregated) history of the snr/heard_secs_ago fields the observer's own /neighbors report carries per neighbor -- report volume per pair is inherently low so, unlike /api/observers/{id}/metrics, there is no resolution/downsampling. Defaults to the last 30 days.", Tag: "observers", QueryParams: []paramMeta{{Name: "since", Description: "RFC3339 lower bound (default: 30 days ago)", Type: "string"}, {Name: "until", Description: "RFC3339 upper bound (default: none)", Type: "string"}}}, "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"}, "GET /api/observers/neighbors": {Summary: "Every observer's reported direct neighbors, network-wide", Description: "Flattens /api/observers/{id}/neighbors across ALL observers into one list -- Tools > Observer Neighbors. Same per-entry semantics (scopes null unless the OTA scope query responded, seenViaPackets cross-references the packet-derived neighbor_edges graph, observer/neighbor name null when unresolved). Blacklisted observers (config.json observerBlacklist) are excluded. Empty list (not an error) when no observer has ever sent a /neighbors report. unknownScopes surfaces region-scope names that turned up in a reported neighbor scope list but aren't part of this deployment's configured hashRegions -- scopes the mesh is using that CoreScope doesn't know about yet.", Tag: "observers", Response: schemaRef("AllObserverNeighborsResponse")}, // 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}}}, "GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"}, "GET /api/packets/{hash}/path": {Summary: "Get a packet's full geographic flood spread", Description: "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (self-advertised GPS when known, same as /api/observers, else its configured IATA code). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. Lat/lon are null for any hop or observer that has no known position -- callers should draw a gap, not guess. Also returns `first`: the single earliest-arriving observation across every station (usually 0 hops, close to the sender) -- an approximate origin landmark, distinct from branches[0] which is the deepest/farthest-traveled branch. Backs the Channels tab's ping-bot \"View path\" map link.", Tag: "packets", Response: schemaRef("PacketPathResponse")}, "GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"}, "GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"}, "GET /api/ping-scores": {Summary: "Ping-score highscore board", Description: "Global (not scoped by region/area) records and leaderboards derived from every ping-bot-triggering channel message ever seen: farthest reach, most hops, widest simultaneous spread, fastest full spread, and most airtime-efficient ping, plus which relay nodes and which observers appear most often. Computed from the same GetPacketPath + LoRa-airtime-estimate logic behind /api/packets/{hash}/path and refreshed on a background interval, so it may lag the very latest ping by a few minutes. Fields are omitted (not zero) until at least one qualifying ping has been recorded.", Tag: "packets", Response: schemaRef("PingScoresResponse")}, "GET /api/analytics/areas": {Summary: "Per-configured-Area node density, cross-area bridge nodes, and position-fix coverage", Description: "Three breakdowns over the drawn-polygon Areas configured via the meshguide.dk sync, distinct from hashRegion scope adoption (see /api/analytics/scope-stats): (1) density, node count/active-degraded-silent health/role mix per area (multi-membership via AreaKeysForPoint, so a node in a sub-area also counts toward its parent region), (2) bridgeNodes, nodes whose packet-derived neighbor_edges reach into at least one OTHER area (single most-specific area via AreaKeyForPoint), ranked by how many other areas they reach -- distinct from the network-wide, area-unaware bridge_score betweenness centrality, (3) positionGaps, per area how many nodes have a real GPS fix vs. how many were only placeable via the same neighbor-centroid estimate View Path's approx markers use (nearestPositionedNeighbor). estimatedNodes is the flat, network-wide list backing positionGaps' approximated counts, with actual estimated coordinates -- used by the Areas tab's \"View Estimated Nodes\" map view. Returns an empty response if no Areas are configured. Cached 30s.", Tag: "analytics", Response: schemaRef("AreaAnalyticsResponse")}, } } // 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"}, "flood_advert_count_7d": map[string]interface{}{"type": "integer", "description": "Distinct FLOOD adverts originated in the last 7 days (zero-hop adverts excluded). Present on the node detail endpoint."}, "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."}, "unscoped_relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: subset of relay_count_24h that were unscoped floods (route_type FLOOD). A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem."}, "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"}, }, }, "HopAnalyticsPacket": map[string]interface{}{ "type": "object", "description": "One transmission that passed through the queried node as a relay hop (issue #1812).", "properties": map[string]interface{}{ "hash": str("Packet hash."), "tsMs": map[string]interface{}{"type": "integer", "description": "First-seen timestamp, Unix milliseconds."}, "hops": map[string]interface{}{"type": "integer", "description": "0-based index of the queried node within the packet's resolved relay path — the value MeshCore firmware compares against flood_max in allowPacketForward. NOT distance to the reporting observer."}, "transport": map[string]interface{}{"type": "string", "enum": []string{"flood", "flood_advert", "flood_unscoped", "direct", "unknown"}, "description": "Which firmware flood.max* knob (if any) caps this packet's hop count."}, "scoped": map[string]interface{}{"type": "boolean", "description": "Whether the transmission carried a region scope (TRANSPORT_FLOOD/TRANSPORT_DIRECT)."}, }, }, "NodeHopAnalyticsResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "packets": map[string]interface{}{"type": "array", "items": schemaRef("HopAnalyticsPacket")}, }, }, "HopDepthBucket": map[string]interface{}{ "type": "object", "description": "How many relay-hop instances (network-wide) saw a given hop count.", "properties": map[string]interface{}{ "hops": map[string]interface{}{"type": "integer", "description": "0-based hop index."}, "count": map[string]interface{}{"type": "integer"}, }, }, "RepeaterUnscopedHopDepth": map[string]interface{}{ "type": "object", "description": "One repeater/room's hop-count profile across the unscoped (plain FLOOD, non-advert) traffic it has relayed.", "properties": map[string]interface{}{ "publicKey": str("Node's public key."), "name": str("Node's display name, or its public key if unnamed."), "count": map[string]interface{}{"type": "integer", "description": "Number of unscoped relay-hop instances at this node."}, "minHops": map[string]interface{}{"type": "integer"}, "medianHops": map[string]interface{}{"type": "number"}, "maxHops": map[string]interface{}{"type": "integer"}, }, }, "HopDepthTimePoint": map[string]interface{}{ "type": "object", "description": "One time bucket's scoped/unscoped median hop depth (5min/1h/6h buckets for 1h/24h/7d windows, same bucketing as ScopeStatsResponse.timeSeries).", "properties": map[string]interface{}{ "t": str("Bucket start, RFC3339 UTC."), "scopedMedianHop": map[string]interface{}{"type": "integer", "nullable": true, "description": "Median hop depth of scoped traffic in this bucket, or null if there was none (0 is a valid median, so absence isn't the same as zero)."}, "unscopedMedianHop": map[string]interface{}{"type": "integer", "nullable": true, "description": "Median hop depth of unscoped traffic in this bucket, or null if there was none."}, }, }, "HopDepthAnalyticsResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "window": str("Time window this response covers: 1h, 24h, or 7d."), "scopedHopDepth": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthBucket"), "description": "Hop-depth histogram for scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic."}, "unscopedHopDepth": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthBucket"), "description": "Hop-depth histogram for unscoped (plain FLOOD) traffic."}, "unscopedByRepeater": map[string]interface{}{"type": "array", "items": schemaRef("RepeaterUnscopedHopDepth"), "description": "Per-repeater/room breakdown of unscoped hop depth, sorted by count descending."}, "timeSeries": map[string]interface{}{"type": "array", "items": schemaRef("HopDepthTimePoint"), "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse."}, }, }, "PacketPathPoint": map[string]interface{}{ "type": "object", "description": "One hop's position along a packet's resolved relay path.", "properties": map[string]interface{}{ "publicKey": str("Node public key (hex)."), "name": str("Node display name, or its public key if unnamed."), "role": str("Node role (e.g. repeater, room), when known."), "lat": map[string]interface{}{"type": "number", "nullable": true, "description": "Null when this node has never advertised a GPS position and has no positioned neighbor either."}, "lon": map[string]interface{}{"type": "number", "nullable": true}, "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this node's own position but a count-weighted centroid of its positioned neighbor_edges neighbors instead -- a last-resort stand-in, not a real fix."}, "approxNeighborCount": map[string]interface{}{"type": "integer", "description": "Present only when approx=true. How many positioned neighbors fed the centroid -- a rough confidence signal, higher is more confident."}, "approxSpreadKm": map[string]interface{}{"type": "number", "nullable": true, "description": "Present only when approx=true and approxNeighborCount>1. Widest distance (km) between any two contributing neighbors -- larger means they disagree more about where 'nearby' is."}, }, }, "PacketPathObserver": map[string]interface{}{ "type": "object", "description": "The station that produced a given branch's observation of a packet path, positioned from its own self-advertised GPS when known (same source as /api/observers), else its configured IATA code, else a weighted centroid of its positioned neighbors (see approx).", "properties": map[string]interface{}{ "publicKey": str("Observer's mesh pubkey, when it has one (some bridge-type observers publish under a device name instead -- see the name-match fallback in GetPacketPath). Empty otherwise."), "name": str("Observer display name."), "iata": str("Observer's configured IATA airport code, when set."), "role": str("Observer's own node role (e.g. repeater, room), when it's known as a mesh node itself -- not just an MQTT/API listener."), "lat": map[string]interface{}{"type": "number", "nullable": true}, "lon": map[string]interface{}{"type": "number", "nullable": true}, "approx": map[string]interface{}{"type": "boolean", "description": "True when lat/lon are not this station's own position but a count-weighted centroid of its positioned neighbors instead -- a last-resort stand-in, not a real fix."}, "approxNeighborCount": map[string]interface{}{"type": "integer", "description": "Present only when approx=true. See PacketPathPoint.approxNeighborCount."}, "approxSpreadKm": map[string]interface{}{"type": "number", "nullable": true, "description": "Present only when approx=true and approxNeighborCount>1. See PacketPathPoint.approxSpreadKm."}, }, }, "PacketPathBranch": map[string]interface{}{ "type": "object", "description": "One station's own route to a packet: how far it traveled to reach them (from that observation's raw hop count, independent of how much of it resolved) and, where resolvable, each hop's position in path order.", "properties": map[string]interface{}{ "hops": map[string]interface{}{"type": "integer", "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved."}, "points": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathPoint"), "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved."}, "observer": schemaRef("PacketPathObserver"), "snr": map[string]interface{}{"type": "number", "nullable": true, "description": "SNR of this station's deepest observation."}, "secondsAfterFirst": map[string]interface{}{"type": "number", "description": "Seconds after the earliest-arriving observation (see PacketPathResponse.first) this branch's own observation arrived. Zero for first itself. Omitted when either timestamp is unknown."}, "distanceFromFirstKm": map[string]interface{}{"type": "number", "description": "Great-circle distance (km) between this branch's own observer and first's observer. Zero for first itself. Omitted when either position is unknown, or when either observer is positioned via approx (an estimate compounding another estimate isn't worth surfacing)."}, }, }, "TouchedAreaShape": map[string]interface{}{ "type": "object", "description": "One configured area's display label plus its drawn boundary, for shading directly on the map. Exactly one of polygon or the latMin/latMax/lonMin/lonMax quartet is present, matching however the area itself was configured.", "properties": map[string]interface{}{ "label": str("The area's display label (e.g. \"Aarhus by\")."), "polygon": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "number"}, "minItems": 2, "maxItems": 2}, "description": "Ordered [lat, lon] pairs tracing the area's drawn boundary. Present only when the area was configured with a polygon rather than a bounding box."}, "latMin": map[string]interface{}{"type": "number", "nullable": true, "description": "Present only when the area was configured as a bounding box rather than a polygon."}, "latMax": map[string]interface{}{"type": "number", "nullable": true}, "lonMin": map[string]interface{}{"type": "number", "nullable": true}, "lonMax": map[string]interface{}{"type": "number", "nullable": true}, }, }, "PacketPathResponse": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "hash": str("The packet hash this path was resolved for."), "branches": map[string]interface{}{"type": "array", "items": schemaRef("PacketPathBranch"), "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route."}, "first": schemaRef("PacketPathBranch"), "touchedAreas": map[string]interface{}{"type": "array", "items": schemaRef("TouchedAreaShape"), "description": "Every configured area any point or observer on the path falls in, deduped and alphabetized by label. Omitted when no areas are configured or none resolved."}, "estimatedAirtimeMs": map[string]interface{}{"type": "number", "nullable": true, "description": "Estimated LoRa Time-on-Air (milliseconds) x distinct-relay-count for this packet's whole flood -- same formula as the Relay Airtime Share analytics metric (issue #1768), applied to a single packet. Assumes the configured/default LoRa PHY preset; relay count is inferred from the union of every hearing station's resolved relay path, not a literal per-retransmission log. Omitted when the in-memory store doesn't have this transmission (DB-only mode, or evicted)."}, "airtimeRelayCount": map[string]interface{}{"type": "integer", "description": "Distinct relay count behind estimatedAirtimeMs. Present only alongside it."}, }, }, "PingScore": map[string]interface{}{ "type": "object", "description": "One ping's computed highscore-relevant stats, derived from the same GetPacketPath + airtime-annotation logic behind /api/packets/{hash}/path.", "properties": map[string]interface{}{ "hash": str("The winning ping's packet hash -- pass to /api/packets/{hash}/path for the full View Path map."), "sender": str("Display name of whoever sent the ping, when resolvable from the channel message."), "channelHash": str("Which channel the ping was sent on."), "timestamp": str("RFC3339 timestamp the ping was first seen."), "stationCount": map[string]interface{}{"type": "integer", "description": "Distinct stations that heard this ping."}, "deepestHops": map[string]interface{}{"type": "integer", "description": "Most relay hops any station's observation of this ping took."}, "deepestNodePubkey": str("Pubkey of the station behind deepestHops."), "deepestNodeName": str("Name of the station behind deepestHops."), "farthestKm": map[string]interface{}{"type": "number", "nullable": true, "description": "Farthest any hearing station was from whoever heard it first, in km. Omitted when no station on this ping's path has a known position."}, "farthestNodePubkey": str("Pubkey of the station behind farthestKm."), "farthestNodeName": str("Name of the station behind farthestKm."), "spreadSeconds": map[string]interface{}{"type": "number", "nullable": true, "description": "How long the flood took to finish reaching every station it ever reached. Omitted when fewer than 2 stations heard it, or no station has timing data."}, "airtimeMs": map[string]interface{}{"type": "number", "nullable": true, "description": "Estimated LoRa Time-on-Air x distinct-relay-count for this ping's whole flood -- same estimate as /api/packets/{hash}/path's estimatedAirtimeMs."}, "relayCount": map[string]interface{}{"type": "integer", "description": "Distinct relay count behind airtimeMs. Present only alongside it."}, "kmPerSecondAirtime": map[string]interface{}{"type": "number", "nullable": true, "description": "farthestKm / (airtimeMs/1000) -- geographic distance covered per second of estimated RF airtime spent relaying this ping. Only set when both farthestKm and airtimeMs (with relayCount>0) are available."}, }, }, "PingLeaderboardEntry": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "pubkey": str("The node/observer's pubkey."), "name": str("Display name, falling back to the raw pubkey when unresolved."), "count": map[string]interface{}{"type": "integer", "description": "How many distinct pings this entry earned credit for."}, }, }, "PingScoresResponse": map[string]interface{}{ "type": "object", "description": "The ping-score highscore board: current records plus leaderboards, global (not scoped by region/area).", "properties": map[string]interface{}{ "generatedAt": str("RFC3339 timestamp this snapshot was computed."), "totalPings": map[string]interface{}{"type": "integer", "description": "Total ping-bot-triggering messages ever seen, whether or not each one resolved to a usable score."}, "farthestPing": schemaRef("PingScore"), "mostHopsPing": schemaRef("PingScore"), "widestSpreadPing": schemaRef("PingScore"), "fastestSpreadPing": map[string]interface{}{"allOf": []interface{}{schemaRef("PingScore")}, "description": "The fastest full spread among pings heard by at least 2 stations -- a lone station is trivially \"instant\" and is excluded so it can't win this record for nothing."}, "mostEfficientPing": schemaRef("PingScore"), "relayLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top nodes ranked by number of distinct pings they appeared as a relay hop in (deduped per ping first, so one busy ping's many branches can't over-credit a relay)."}, "observerLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top observers ranked by number of pings they were the first station to hear."}, }, }, "AreaDensity": map[string]interface{}{ "type": "object", "description": "One configured area's node count, active/degraded/silent health breakdown, and role mix. Multi-membership: a node in a sub-area also counts toward its parent region.", "properties": map[string]interface{}{ "areaKey": str("The area's config key."), "label": str("The area's display label."), "total": map[string]interface{}{"type": "integer", "description": "Total nodes with a real GPS fix inside this area (or any of its sub-areas)."}, "active": map[string]interface{}{"type": "integer", "description": "Nodes heard within their role's active threshold."}, "degraded": map[string]interface{}{"type": "integer", "description": "Nodes heard within their role's degraded threshold but not active."}, "silent": map[string]interface{}{"type": "integer", "description": "Nodes not heard within either threshold."}, "roleCounts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Node count per role string."}, }, }, "AreaBridgeNode": map[string]interface{}{ "type": "object", "description": "One node whose packet-derived neighbor_edges reach into at least one other configured area than its own -- distinct from the network-wide, area-unaware bridge_score betweenness centrality.", "properties": map[string]interface{}{ "publicKey": str("The node's pubkey."), "name": str("Display name, falling back to the raw pubkey when unresolved."), "areaKey": str("This node's own single most-specific area."), "label": str("That area's display label."), "edgeCount": map[string]interface{}{"type": "integer", "description": "Number of neighbor_edges reaching into a different area than this node's own."}, "otherAreaCount": map[string]interface{}{"type": "integer", "description": "Number of distinct other areas reached."}, "otherAreas": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Display labels of every other area reached."}, }, }, "AreaPositionGap": map[string]interface{}{ "type": "object", "description": "One configured area's position-fix coverage: nodes with a real GPS fix vs. nodes only placeable via nearestPositionedNeighbor's estimate.", "properties": map[string]interface{}{ "areaKey": str("The area's config key."), "label": str("The area's display label."), "realFix": map[string]interface{}{"type": "integer", "description": "Nodes in this area with an actual reported GPS position."}, "approximated": map[string]interface{}{"type": "integer", "description": "Nodes with no GPS fix whose neighbor-centroid estimate landed in this area."}, }, }, "EstimatedAreaNode": map[string]interface{}{ "type": "object", "description": "One node with no real GPS fix, plotted at its nearestPositionedNeighbor weighted-centroid estimate -- the same technique View Path's approximate markers use.", "properties": map[string]interface{}{ "publicKey": str("The node's pubkey."), "name": str("Display name, falling back to the raw pubkey when unresolved."), "areaKey": str("The area this estimated position falls inside."), "label": str("That area's display label."), "lat": map[string]interface{}{"type": "number", "description": "Estimated latitude (weighted centroid of positioned neighbors)."}, "lon": map[string]interface{}{"type": "number", "description": "Estimated longitude."}, "contributorCount": map[string]interface{}{"type": "integer", "description": "How many positioned neighbors fed into the estimate."}, "spreadKm": map[string]interface{}{"type": "number", "description": "How spread out (km) those contributing neighbors were -- a rough confidence signal, wider spread means a less certain estimate."}, }, }, "AreaAnalyticsResponse": map[string]interface{}{ "type": "object", "description": "Node density/health, cross-area bridge nodes, and position-fix coverage per configured Area (the drawn-polygon regions from the meshguide.dk sync, distinct from hashRegion scope adoption). Empty when no Areas are configured.", "properties": map[string]interface{}{ "density": map[string]interface{}{"type": "array", "items": schemaRef("AreaDensity")}, "bridgeNodes": map[string]interface{}{"type": "array", "items": schemaRef("AreaBridgeNode"), "description": "Top cross-area bridge nodes, ranked by how many other areas they reach."}, "positionGaps": map[string]interface{}{"type": "array", "items": schemaRef("AreaPositionGap")}, "unpositionedTotal": map[string]interface{}{"type": "integer", "description": "Every node with no real GPS fix, regardless of area."}, "unpositionedNoNeighborFix": map[string]interface{}{"type": "integer", "description": "The subset of unpositionedTotal that also has no positioned neighbor to estimate from -- can't be placed even approximately, so absent from every area's positionGaps.approximated."}, "estimatedNodes": map[string]interface{}{"type": "array", "items": schemaRef("EstimatedAreaNode"), "description": "Flat, network-wide list of every node behind positionGaps' approximated counts, with actual estimated coordinates for plotting on a map."}, }, }, "AllObserverNeighborsEntry": map[string]interface{}{ "type": "object", "description": "One observer's firmware-reported direct neighbor, flattened with which observer it came from.", "properties": map[string]interface{}{ "observerId": str("The reporting observer's ID."), "observerName": map[string]interface{}{"type": "string", "nullable": true, "description": "Observer display name, null when unresolved."}, "observerIata": map[string]interface{}{"type": "string", "nullable": true, "description": "Observer's IATA region code, when set."}, "neighborPubkey": str("The neighbor's pubkey."), "neighborName": map[string]interface{}{"type": "string", "nullable": true, "description": "Neighbor display name, null when the pubkey doesn't resolve to a known node."}, "neighborRole": map[string]interface{}{"type": "string", "nullable": true}, "scopes": map[string]interface{}{"type": "string", "nullable": true, "description": "Null unless the neighbor's OTA scope query responded (status=\"responded\")."}, "status": str("The /neighbors report's status for this entry (e.g. \"responded\", \"timeout\")."), "seenViaPackets": map[string]interface{}{"type": "boolean", "description": "Cross-references the packet-path-inferred neighbor_edges graph -- false means this firmware-confirmed neighbor has never had a resolved packet path to the observer (possible coverage gap or packet loss, not necessarily a fault)."}, "reportedAt": str("RFC3339 timestamp of the /neighbors report this row came from."), }, }, "UnknownScopeEntry": map[string]interface{}{ "type": "object", "description": "A region-scope name seen in a reported neighbor scope list that isn't part of this deployment's configured hashRegions.", "properties": map[string]interface{}{ "scope": str("The scope name (e.g. \"#dk-storkbh\"), always #-prefixed."), "count": map[string]interface{}{"type": "integer", "description": "Number of distinct neighbors (by display name, falling back to pubkey) that reported this scope."}, "examples": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Up to 5 example neighbor display names/pubkeys that reported this scope."}, }, }, "AllObserverNeighborsResponse": map[string]interface{}{ "type": "object", "description": "Every observer's reported direct neighbors, network-wide (Tools > Observer Neighbors). Empty (not an error) when no observer has ever sent a /neighbors report.", "properties": map[string]interface{}{ "neighbors": map[string]interface{}{"type": "array", "items": schemaRef("AllObserverNeighborsEntry")}, "unknownScopes": map[string]interface{}{"type": "array", "items": schemaRef("UnknownScopeEntry"), "description": "Region-scope names observed in the wild that aren't part of this deployment's configured hashRegions, ranked by how many distinct neighbors reported them."}, }, }, } } // buildOpenAPISpec constructs an OpenAPI 3.0 spec by walking the mux router. func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} { descriptions := routeDescriptions() // Collect routes from the router type routeInfo struct { path string method string authReq bool } var routes []routeInfo router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { path, err := route.GetPathTemplate() if err != nil { return nil } if !strings.HasPrefix(path, "/api/") { return nil } // Skip the spec/docs endpoints themselves if path == "/api/spec" || path == "/api/docs" { return nil } methods, err := route.GetMethods() if err != nil { return nil } for _, m := range methods { routes = append(routes, routeInfo{path: path, method: m}) } return nil }) // Sort routes for deterministic output sort.Slice(routes, func(i, j int) bool { if routes[i].path != routes[j].path { return routes[i].path < routes[j].path } return routes[i].method < routes[j].method }) // Build paths object paths := make(map[string]interface{}) tagSet := make(map[string]bool) for _, ri := range routes { key := ri.method + " " + ri.path meta, hasMeta := descriptions[key] // 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 { if hasMeta { return meta.Summary } return ri.path }(), "responses": map[string]interface{}{ "200": map[string]interface{}{ "description": "Success", "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": respSchema, }, }, }, }, } if hasMeta { if meta.Description != "" { op["description"] = meta.Description } if meta.Tag != "" { op["tags"] = []string{meta.Tag} tagSet[meta.Tag] = true } if meta.Auth { op["security"] = []map[string]interface{}{ {"ApiKeyAuth": []string{}}, } } // Add query parameters if len(meta.QueryParams) > 0 { params := make([]interface{}, 0, len(meta.QueryParams)) for _, qp := range meta.QueryParams { p := map[string]interface{}{ "name": qp.Name, "in": "query", "required": qp.Required, "schema": map[string]interface{}{"type": qp.Type}, } if qp.Description != "" { p["description"] = qp.Description } params = append(params, p) } op["parameters"] = params } } // Extract path parameters from {name} patterns pathParams := extractPathParams(openAPIPath) if len(pathParams) > 0 { existing, _ := op["parameters"].([]interface{}) for _, pp := range pathParams { existing = append(existing, map[string]interface{}{ "name": pp, "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}, }) } op["parameters"] = existing } // Add to paths methodLower := strings.ToLower(ri.method) if _, ok := paths[openAPIPath]; !ok { paths[openAPIPath] = make(map[string]interface{}) } paths[openAPIPath].(map[string]interface{})[methodLower] = op } // Build tags array (sorted) tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets"} tagDescriptions := map[string]string{ "admin": "Server administration and diagnostics", "analytics": "Network analytics and statistics", "channels": "Mesh channel operations", "config": "Server configuration", "nodes": "Mesh node operations", "observers": "Packet observer/gateway operations", "packets": "Packet capture and decoding", } var tags []interface{} for _, t := range tagOrder { if tagSet[t] { tags = append(tags, map[string]interface{}{ "name": t, "description": tagDescriptions[t], }) } } spec := map[string]interface{}{ "openapi": "3.0.3", "info": map[string]interface{}{ "title": "CoreScope API", "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics.", "version": version, "license": map[string]interface{}{ "name": "MIT", }, }, "paths": paths, "tags": tags, "components": map[string]interface{}{ "securitySchemes": map[string]interface{}{ "ApiKeyAuth": map[string]interface{}{ "type": "apiKey", "in": "header", "name": "X-API-Key", }, }, "schemas": componentSchemas(), }, } return spec } // extractPathParams returns parameter names from a mux-style path like /api/nodes/{pubkey}. func extractPathParams(path string) []string { var params []string for { start := strings.Index(path, "{") if start == -1 { break } end := strings.Index(path[start:], "}") if end == -1 { break } params = append(params, path[start+1:start+end]) path = path[start+end+1:] } return params } // handleOpenAPISpec serves the OpenAPI 3.0 spec as JSON. // The router is injected via RegisterRoutes storing it on the Server. func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) { spec := buildOpenAPISpec(s.router, s.version) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(spec); err != nil { http.Error(w, fmt.Sprintf("failed to encode spec: %v", err), http.StatusInternalServerError) } } // handleSwaggerUI serves a minimal Swagger UI page. func (s *Server) handleSwaggerUI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, swaggerUIHTML) } const swaggerUIHTML = ` CoreScope API — Swagger UI
`