From 31bcda03b938d69b865ce8f178f8a90707e80c02 Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Thu, 28 May 2026 15:11:04 -0700 Subject: [PATCH] add swagger change to named functions in route handlers for swagger docs --- README.md | 65 +- cmd/tower/main.go | 34 + docs/docs.go | 1786 ++++++++++++++++++++++++++++ docs/swagger.json | 1766 +++++++++++++++++++++++++++ docs/swagger.yaml | 1167 ++++++++++++++++++ go.mod | 14 +- go.sum | 47 + internal/api/handlers/brokers.go | 22 +- internal/api/handlers/channels.go | 214 ++-- internal/api/handlers/iatas.go | 47 +- internal/api/handlers/messages.go | 41 +- internal/api/handlers/nodes.go | 182 +-- internal/api/handlers/observers.go | 284 +++-- internal/api/handlers/packets.go | 62 +- internal/api/handlers/regions.go | 49 +- internal/api/handlers/stats.go | 128 +- internal/api/reader.go | 21 +- internal/api/router/router.go | 10 + 18 files changed, 5489 insertions(+), 450 deletions(-) create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml diff --git a/README.md b/README.md index 34f67c6..24dee97 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,67 @@ Tower server. --- +## API Documentation (Swagger) + +Tower uses [swaggo/swag](https://github.com/swaggo/swag) to generate OpenAPI +documentation from annotations in the handler comments. + +### Viewing the docs + +Start the server and open: + +``` +http://localhost:8080/swagger/index.html +``` + +### Regenerating after API changes + +After adding or modifying any handler, regenerate the docs: + +```bash +swag init -g cmd/tower/main.go -o docs +``` + +Commit the updated `docs/` directory alongside your handler changes. + +### Install swag + +```bash +go install github.com/swaggo/swag/cmd/swag@latest +``` + +### Annotation format + +Each handler closure should have a godoc-style annotation block immediately +above the `r.Get()`/`r.Post()` call: + +```go +// listThings godoc +// +// @Summary Short description shown in the UI +// @Tags TagName +// @Produce json +// @Param paramName query string false "Description" +// @Param id path string true "Resource ID" +// @Success 200 {object} api.MyResponseType +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /things [get] +r.Get("/", func(w http.ResponseWriter, r *http.Request) { +``` + +**Param types:** `query`, `path`, `header`, `body` +**Required:** use `true` or `false` as the fifth field +**Pagination params** (`cursor`, `limit`) should always be `false` + +For paginated responses use the generic page wrapper: + +```go +// @Success 200 {object} api.Page[api.MyType] +``` + +--- + ## Road Map ### Done @@ -307,12 +368,14 @@ Tower server. - [x] REST API: Packets (list + detail) - [x] REST API: Stats - [x] Materialized view refresh (mv_hourly_iata_stats, mv_top_nodes_by_iata) +- [x] Swagger/OpenAPI documentation via swaggo/swag ### In progress / next - [ ] Path resolution (node short ID lookup) - [ ] Propagation time calculation - [ ] Routes and traces endpoints +- [ ] WebSocket subscription unsubscribe (scaffolded, not implemented) ### Future @@ -321,6 +384,4 @@ Tower server. - [ ] Channel key rotation / multi-key support (scaffolded) - [ ] Caddy reverse proxy config for production - [ ] Region management via API (currently config-file only) -- [ ] WebSocket subscription unsubscribe (scaffolded, not implemented) - [ ] Observer owner tracking (schema exists, API excluded by design) -- [ ] Swagger/OpenAPI documentation via swaggo/swag diff --git a/cmd/tower/main.go b/cmd/tower/main.go index 6005771..b636e69 100644 --- a/cmd/tower/main.go +++ b/cmd/tower/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/MeshCore-Tower/tower-server/db" + _ "github.com/MeshCore-Tower/tower-server/docs" "github.com/MeshCore-Tower/tower-server/internal/api/router" "github.com/MeshCore-Tower/tower-server/internal/config" "github.com/MeshCore-Tower/tower-server/internal/hub" @@ -23,6 +24,39 @@ import ( "github.com/joho/godotenv" ) +// @title MeshCore Tower API +// @version 1.0 +// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket. +// @termsOfService https://github.com/MeshCore-Tower/tower-server + +// @contact.name MeshCore Tower +// @contact.url https://github.com/MeshCore-Tower/tower-server + +// @license.name MIT + +// @host localhost:8080 +// @BasePath /api/v1 + +// @schemes http https + +// @tag.name IATAs +// @tag.description Airport/location codes that group observers and packets +// @tag.name Regions +// @tag.description Super-regions grouping multiple IATAs +// @tag.name Observers +// @tag.description MeshCore MQTT observers (gateways) +// @tag.name Nodes +// @tag.description MeshCore radio nodes +// @tag.name Packets +// @tag.description LoRa packets heard by observers +// @tag.name Channels +// @tag.description MeshCore group text channels +// @tag.name Messages +// @tag.description Decrypted channel messages +// @tag.name Brokers +// @tag.description MQTT broker connection status +// @tag.name Stats +// @tag.description Network statistics and time series func main() { _ = godotenv.Load() addr := os.Getenv("LISTEN_ADDR") diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..b02f740 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1786 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "https://github.com/MeshCore-Tower/tower-server", + "contact": { + "name": "MeshCore Tower", + "url": "https://github.com/MeshCore-Tower/tower-server" + }, + "license": { + "name": "MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/brokers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Brokers" + ], + "summary": "List all MQTT brokers and their connection status", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BrokerStatus" + } + } + } + } + } + }, + "/channels": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "List channels", + "parameters": [ + { + "type": "string", + "description": "Single-byte channel hash (hex)", + "name": "hash", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/channels/{channelID}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "Get channel detail", + "parameters": [ + { + "type": "integer", + "description": "Channel integer ID", + "name": "channelID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Channel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/channels/{channelID}/messages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "List messages for a channel", + "parameters": [ + { + "type": "integer", + "description": "Channel integer ID", + "name": "channelID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Return messages after this epoch ms", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Message ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/iatas": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IATAs" + ], + "summary": "List all IATA codes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.IATA" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/iatas/{iata}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IATAs" + ], + "summary": "Get a single IATA code", + "parameters": [ + { + "type": "string", + "description": "3-letter IATA code", + "name": "iata", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.IATA" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/messages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "List channel messages", + "parameters": [ + { + "type": "integer", + "description": "Filter by channel integer ID (mutually exclusive with channelHash)", + "name": "channelID", + "in": "query" + }, + { + "type": "string", + "description": "Filter by channel hash byte hex (mutually exclusive with channelID)", + "name": "channelHash", + "in": "query" + }, + { + "type": "integer", + "description": "Return messages after this epoch ms", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Message ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List nodes", + "parameters": [ + { + "type": "integer", + "description": "Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Node type name (companion, repeater, room_server, sensor)", + "name": "typeName", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Partial case-insensitive name match", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Exact public key match (hex)", + "name": "pubkey", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter to nodes with firmware \u003e= 1.14.0", + "name": "supportsMultibytePaths", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter to nodes with firmware \u003e= 1.11.0", + "name": "supportsMultibyteTraces", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes/{nodeId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "Get node detail", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Node" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes/{nodeId}/observations": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List packet observations originating from a node", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Observation ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "List observers", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Filter by observer type (e.g. meshcoretomqtt, meshcore-ha)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Filter by broker name", + "name": "broker", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (online or offline)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Partial case-insensitive display name match", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "Get observer detail", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Observer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}/adverts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "List advert packets heard by an observer", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Observation ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}/telemetry": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "Get observer telemetry history", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Duration window e.g. 24h, 48h, 168h (default 24h)", + "name": "range", + "in": "query" + }, + { + "type": "integer", + "description": "Return points after this telemetry ID for WS reconnection backfill", + "name": "afterId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ObserverTelemetry" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/packets": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Packets" + ], + "summary": "List packets", + "parameters": [ + { + "type": "integer", + "description": "Filter by payload type integer", + "name": "payloadType", + "in": "query" + }, + { + "type": "string", + "description": "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)", + "name": "payloadTypeName", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)", + "name": "routeType", + "in": "query" + }, + { + "type": "string", + "description": "Filter by latest observation IATA (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by first_heard_at \u003e= since (epoch ms)", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by first_heard_at \u003c= until (epoch ms)", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "last_heard_at epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/packets/{packetHash}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Packets" + ], + "summary": "Get full packet detail", + "parameters": [ + { + "type": "string", + "description": "Packet hash (hex)", + "name": "packetHash", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Packet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/regions": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Regions" + ], + "summary": "List all regions", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.RegionSummary" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/regions/{regionId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Regions" + ], + "summary": "Get a single region", + "parameters": [ + { + "type": "integer", + "description": "Region ID", + "name": "regionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Region" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/observations": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Hourly observation time series", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default 7 days ago)", + "name": "since", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ObservationPoint" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/overview": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Network overview stats (last 24h)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.StatsOverview" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/payload-breakdown": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Observation counts by payload type (last 24h by default)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default last 24h)", + "name": "since", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.PayloadBreakdownItem" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/top-nodes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Top N nodes by observation count (from materialized view)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.TopNode" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/top-observers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Top N observers by observation count (last 24h by default)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default last 24h)", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.TopObserver" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + } + }, + "definitions": { + "api.Channel": { + "type": "object", + "properties": { + "channelHash": { + "description": "hex-encoded single-byte hash", + "type": "string" + }, + "hashtag": { + "description": "tag name without # prefix", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHashtag": { + "description": "true if derived from a hashtag PSK", + "type": "boolean" + }, + "keyFingerprint": { + "description": "first 8 bytes of SHA256(key), hex-encoded", + "type": "string" + }, + "keyKnown": { + "description": "true if Tower has a decryption key", + "type": "boolean" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "messageCount": { + "type": "integer" + }, + "name": { + "description": "display name, nil if not set", + "type": "string" + } + } + }, + "api.IATA": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "iata": { + "type": "string" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + } + } + }, + "api.Node": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "id": { + "type": "string" + }, + "lastAdvertAt": { + "description": "epoch ms, nil if no advert received", + "type": "integer" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "locationSource": { + "description": "e.g. \"advert\", \"manual\"", + "type": "string" + }, + "metadata": { + "description": "raw JSONB metadata" + }, + "minFirmwareVersion": { + "description": "derived from capability flags", + "type": "string" + }, + "name": { + "type": "string" + }, + "nodeType": { + "description": "1=companion, 2=repeater, 3=room server", + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "publicKey": { + "description": "hex-encoded public key", + "type": "string" + }, + "supportsMultibytePaths": { + "description": "firmware \u003e= 1.14.0", + "type": "boolean" + }, + "supportsMultibyteTraces": { + "description": "firmware \u003e= 1.11.0", + "type": "boolean" + } + } + }, + "api.ObservationPoint": { + "type": "object", + "properties": { + "activeObservers": { + "type": "integer" + }, + "hour": { + "description": "epoch ms, start of bucket", + "type": "integer" + }, + "iata": { + "type": "string" + }, + "observationCount": { + "type": "integer" + }, + "uniquePackets": { + "type": "integer" + } + } + }, + "api.Observer": { + "type": "object", + "properties": { + "batteryLevel": { + "description": "volts, nil if mains powered", + "type": "number" + }, + "brokers": { + "description": "broker names this observer has been seen on", + "type": "array", + "items": { + "$ref": "#/definitions/api.ObserverBroker" + } + }, + "displayName": { + "description": "friendly name from /status messages", + "type": "string" + }, + "firmwareBuild": { + "type": "string" + }, + "firmwareVersion": { + "type": "string" + }, + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "hardwareModel": { + "type": "string" + }, + "iata": { + "description": "most recently heard IATA", + "type": "string" + }, + "id": { + "type": "string" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lastStatusAt": { + "description": "epoch ms", + "type": "integer" + }, + "observationCount": { + "type": "integer" + }, + "observerType": { + "description": "e.g. \"meshcoretomqtt\", \"meshcoreha\"", + "type": "string" + }, + "publicKey": { + "description": "hex-encoded public key", + "type": "string" + }, + "radioBwKhz": { + "description": "bandwidth in kHz", + "type": "number" + }, + "radioCr": { + "description": "coding rate denominator", + "type": "integer" + }, + "radioFreqMhz": { + "description": "MHz e.g. 910.525", + "type": "number" + }, + "radioSf": { + "description": "LoRa spreading factor", + "type": "integer" + }, + "softwareVersion": { + "type": "string" + }, + "status": { + "description": "\"online\" or \"offline\" derived from last_status_at", + "type": "string" + }, + "statusMetadata": { + "description": "raw /status JSON payload" + }, + "uptimeSeconds": { + "type": "integer" + } + } + }, + "api.ObserverBroker": { + "type": "object", + "properties": { + "lastPacketAt": { + "description": "epoch ms, last packet received via this broker; 0 if none", + "type": "integer" + }, + "lastSeenAt": { + "description": "epoch ms, last time observer was seen on this broker", + "type": "integer" + }, + "name": { + "description": "broker name e.g. \"mqtt1\"", + "type": "string" + } + } + }, + "api.ObserverTelemetry": { + "type": "object", + "properties": { + "interval": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ObserverTelemetryPoint" + } + }, + "range": { + "type": "string" + } + } + }, + "api.ObserverTelemetryPoint": { + "type": "object", + "properties": { + "airtimeRxPct": { + "type": "number" + }, + "airtimeTxPct": { + "type": "number" + }, + "batteryMv": { + "type": "integer" + }, + "noiseFloorDb": { + "type": "number" + }, + "queueLength": { + "type": "integer" + }, + "receiveErrors": { + "type": "integer" + }, + "t": { + "description": "epoch ms", + "type": "integer" + }, + "uptimeSeconds": { + "type": "integer" + } + } + }, + "api.Packet": { + "type": "object", + "properties": { + "channelHash": { + "description": "hex-encoded", + "type": "string" + }, + "decrypted": { + "type": "boolean" + }, + "firstHeardAt": { + "description": "epoch ms", + "type": "integer" + }, + "lastHeardAt": { + "description": "epoch ms", + "type": "integer" + }, + "observationCount": { + "type": "integer" + }, + "observations": { + "type": "array", + "items": { + "$ref": "#/definitions/api.PacketObservationDetail" + } + }, + "originPubkey": { + "description": "hex-encoded", + "type": "string" + }, + "packetHash": { + "description": "hex-encoded", + "type": "string" + }, + "parsedPayload": {}, + "payloadType": { + "type": "integer" + }, + "payloadTypeName": { + "type": "string" + }, + "payloadVersion": { + "type": "integer" + }, + "rawPayload": { + "description": "hex-encoded", + "type": "string" + }, + "routeType": { + "type": "integer" + }, + "routeTypeName": { + "type": "string" + }, + "transportCodes": { + "description": "hex-encoded", + "type": "string" + } + } + }, + "api.PacketObservationDetail": { + "type": "object", + "properties": { + "hashSize": { + "type": "integer" + }, + "heardAt": { + "description": "epoch ms", + "type": "integer" + }, + "hopCount": { + "type": "integer" + }, + "iata": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "observerId": { + "type": "string" + }, + "observerName": { + "type": "string" + }, + "pathBytes": { + "description": "hex-encoded", + "type": "string" + }, + "pathLengthByte": { + "type": "integer" + }, + "propagationTimeMs": { + "type": "integer" + }, + "radio": { + "$ref": "#/definitions/api.PacketRadio" + }, + "resolvedPath": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ResolvedHop" + } + }, + "rssi": { + "type": "integer" + }, + "snr": { + "type": "number" + }, + "sourceBroker": { + "type": "string" + } + } + }, + "api.PacketRadio": { + "type": "object", + "properties": { + "bandwidthKhz": { + "type": "number" + }, + "codingRate": { + "type": "integer" + }, + "freqMhz": { + "type": "number" + }, + "spreadFactor": { + "type": "integer" + } + } + }, + "api.PayloadBreakdownItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "payloadType": { + "type": "integer" + }, + "payloadTypeName": { + "type": "string" + } + } + }, + "api.Region": { + "type": "object", + "properties": { + "centerLat": { + "description": "map center latitude", + "type": "number" + }, + "centerLng": { + "description": "map center longitude", + "type": "number" + }, + "description": { + "type": "string" + }, + "iatas": { + "description": "member IATA codes", + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "description": "URL-safe identifier e.g. \"western-canada\"", + "type": "string" + }, + "zoomLevel": { + "description": "suggested map zoom level", + "type": "integer" + } + } + }, + "api.RegionSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "description": "URL-safe identifier e.g. \"western-canada\"", + "type": "string" + } + } + }, + "api.ResolvedHop": { + "type": "object", + "properties": { + "confidence": { + "description": "\"high\", \"low\", \"unknown\"", + "type": "string" + }, + "node": { + "$ref": "#/definitions/api.ResolvedNode" + } + } + }, + "api.ResolvedNode": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "publicKey": { + "description": "hex-encoded prefix", + "type": "string" + } + } + }, + "api.StatsOverview": { + "type": "object", + "properties": { + "activeIatas": { + "type": "integer" + }, + "activeObservers": { + "type": "integer" + }, + "totalObservations": { + "type": "integer" + }, + "totalPackets": { + "type": "integer" + }, + "windowHours": { + "description": "always 24 for now", + "type": "integer" + } + } + }, + "api.TopNode": { + "type": "object", + "properties": { + "iata": { + "type": "string" + }, + "lastHeard": { + "description": "epoch ms", + "type": "integer" + }, + "nodeId": { + "type": "string" + }, + "nodeName": { + "type": "string" + }, + "nodeType": { + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "observationCount": { + "type": "integer" + } + } + }, + "api.TopObserver": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "iata": { + "type": "string" + }, + "observationCount": { + "type": "integer" + }, + "observerId": { + "type": "string" + }, + "observerType": { + "type": "string" + } + } + }, + "handlers.APIError": { + "type": "object", + "properties": { + "code": { + "description": "e.g. \"not_found\", \"bad_request\"", + "type": "string" + }, + "message": { + "description": "e.g. \"channel not found\"", + "type": "string" + } + } + }, + "handlers.BrokerStatus": { + "type": "object", + "properties": { + "connected": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + } + }, + "tags": [ + { + "description": "Airport/location codes that group observers and packets", + "name": "IATAs" + }, + { + "description": "Super-regions grouping multiple IATAs", + "name": "Regions" + }, + { + "description": "MeshCore MQTT observers (gateways)", + "name": "Observers" + }, + { + "description": "MeshCore radio nodes", + "name": "Nodes" + }, + { + "description": "LoRa packets heard by observers", + "name": "Packets" + }, + { + "description": "MeshCore group text channels", + "name": "Channels" + }, + { + "description": "Decrypted channel messages", + "name": "Messages" + }, + { + "description": "MQTT broker connection status", + "name": "Brokers" + }, + { + "description": "Network statistics and time series", + "name": "Stats" + } + ] +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/api/v1", + Schemes: []string{"http", "https"}, + Title: "MeshCore Tower API", + Description: "MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..061ea8b --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1766 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.", + "title": "MeshCore Tower API", + "termsOfService": "https://github.com/MeshCore-Tower/tower-server", + "contact": { + "name": "MeshCore Tower", + "url": "https://github.com/MeshCore-Tower/tower-server" + }, + "license": { + "name": "MIT" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/api/v1", + "paths": { + "/brokers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Brokers" + ], + "summary": "List all MQTT brokers and their connection status", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BrokerStatus" + } + } + } + } + } + }, + "/channels": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "List channels", + "parameters": [ + { + "type": "string", + "description": "Single-byte channel hash (hex)", + "name": "hash", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/channels/{channelID}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "Get channel detail", + "parameters": [ + { + "type": "integer", + "description": "Channel integer ID", + "name": "channelID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Channel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/channels/{channelID}/messages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Channels" + ], + "summary": "List messages for a channel", + "parameters": [ + { + "type": "integer", + "description": "Channel integer ID", + "name": "channelID", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Return messages after this epoch ms", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Message ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/iatas": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IATAs" + ], + "summary": "List all IATA codes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.IATA" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/iatas/{iata}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "IATAs" + ], + "summary": "Get a single IATA code", + "parameters": [ + { + "type": "string", + "description": "3-letter IATA code", + "name": "iata", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.IATA" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/messages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Messages" + ], + "summary": "List channel messages", + "parameters": [ + { + "type": "integer", + "description": "Filter by channel integer ID (mutually exclusive with channelHash)", + "name": "channelID", + "in": "query" + }, + { + "type": "string", + "description": "Filter by channel hash byte hex (mutually exclusive with channelID)", + "name": "channelHash", + "in": "query" + }, + { + "type": "integer", + "description": "Return messages after this epoch ms", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Message ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List nodes", + "parameters": [ + { + "type": "integer", + "description": "Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Node type name (companion, repeater, room_server, sensor)", + "name": "typeName", + "in": "query" + }, + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Partial case-insensitive name match", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Exact public key match (hex)", + "name": "pubkey", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter to nodes with firmware \u003e= 1.14.0", + "name": "supportsMultibytePaths", + "in": "query" + }, + { + "type": "boolean", + "description": "Filter to nodes with firmware \u003e= 1.11.0", + "name": "supportsMultibyteTraces", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes/{nodeId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "Get node detail", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Node" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/nodes/{nodeId}/observations": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Nodes" + ], + "summary": "List packet observations originating from a node", + "parameters": [ + { + "type": "string", + "description": "Node UUID", + "name": "nodeId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Observation ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "List observers", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "string", + "description": "Filter by observer type (e.g. meshcoretomqtt, meshcore-ha)", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "Filter by broker name", + "name": "broker", + "in": "query" + }, + { + "type": "string", + "description": "Filter by status (online or offline)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Partial case-insensitive display name match", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "description": "last_seen epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "Get observer detail", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Observer" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}/adverts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "List advert packets heard by an observer", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Observation ID of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/observers/{observerId}/telemetry": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Observers" + ], + "summary": "Get observer telemetry history", + "parameters": [ + { + "type": "string", + "description": "Observer UUID", + "name": "observerId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Duration window e.g. 24h, 48h, 168h (default 24h)", + "name": "range", + "in": "query" + }, + { + "type": "integer", + "description": "Return points after this telemetry ID for WS reconnection backfill", + "name": "afterId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ObserverTelemetry" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/packets": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Packets" + ], + "summary": "List packets", + "parameters": [ + { + "type": "integer", + "description": "Filter by payload type integer", + "name": "payloadType", + "in": "query" + }, + { + "type": "string", + "description": "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)", + "name": "payloadTypeName", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)", + "name": "routeType", + "in": "query" + }, + { + "type": "string", + "description": "Filter by latest observation IATA (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by first_heard_at \u003e= since (epoch ms)", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by first_heard_at \u003c= until (epoch ms)", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "last_heard_at epoch ms of last item for pagination", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/packets/{packetHash}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Packets" + ], + "summary": "Get full packet detail", + "parameters": [ + { + "type": "string", + "description": "Packet hash (hex)", + "name": "packetHash", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Packet" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/regions": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Regions" + ], + "summary": "List all regions", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.RegionSummary" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/regions/{regionId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Regions" + ], + "summary": "Get a single region", + "parameters": [ + { + "type": "integer", + "description": "Region ID", + "name": "regionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.Region" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/observations": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Hourly observation time series", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default 7 days ago)", + "name": "since", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ObservationPoint" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/overview": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Network overview stats (last 24h)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.StatsOverview" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/payload-breakdown": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Observation counts by payload type (last 24h by default)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default last 24h)", + "name": "since", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.PayloadBreakdownItem" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/top-nodes": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Top N nodes by observation count (from materialized view)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.TopNode" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + }, + "/stats/top-observers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Stats" + ], + "summary": "Top N observers by observation count (last 24h by default)", + "parameters": [ + { + "type": "string", + "description": "Filter by IATA code (case-insensitive)", + "name": "iata", + "in": "query" + }, + { + "type": "integer", + "description": "Start of window epoch ms (default last 24h)", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Max results (default 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/api.TopObserver" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.APIError" + } + } + } + } + } + }, + "definitions": { + "api.Channel": { + "type": "object", + "properties": { + "channelHash": { + "description": "hex-encoded single-byte hash", + "type": "string" + }, + "hashtag": { + "description": "tag name without # prefix", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isHashtag": { + "description": "true if derived from a hashtag PSK", + "type": "boolean" + }, + "keyFingerprint": { + "description": "first 8 bytes of SHA256(key), hex-encoded", + "type": "string" + }, + "keyKnown": { + "description": "true if Tower has a decryption key", + "type": "boolean" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "messageCount": { + "type": "integer" + }, + "name": { + "description": "display name, nil if not set", + "type": "string" + } + } + }, + "api.IATA": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "iata": { + "type": "string" + }, + "lat": { + "type": "number" + }, + "lon": { + "type": "number" + } + } + }, + "api.Node": { + "type": "object", + "properties": { + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "id": { + "type": "string" + }, + "lastAdvertAt": { + "description": "epoch ms, nil if no advert received", + "type": "integer" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lat": { + "type": "number" + }, + "lng": { + "type": "number" + }, + "locationSource": { + "description": "e.g. \"advert\", \"manual\"", + "type": "string" + }, + "metadata": { + "description": "raw JSONB metadata" + }, + "minFirmwareVersion": { + "description": "derived from capability flags", + "type": "string" + }, + "name": { + "type": "string" + }, + "nodeType": { + "description": "1=companion, 2=repeater, 3=room server", + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "publicKey": { + "description": "hex-encoded public key", + "type": "string" + }, + "supportsMultibytePaths": { + "description": "firmware \u003e= 1.14.0", + "type": "boolean" + }, + "supportsMultibyteTraces": { + "description": "firmware \u003e= 1.11.0", + "type": "boolean" + } + } + }, + "api.ObservationPoint": { + "type": "object", + "properties": { + "activeObservers": { + "type": "integer" + }, + "hour": { + "description": "epoch ms, start of bucket", + "type": "integer" + }, + "iata": { + "type": "string" + }, + "observationCount": { + "type": "integer" + }, + "uniquePackets": { + "type": "integer" + } + } + }, + "api.Observer": { + "type": "object", + "properties": { + "batteryLevel": { + "description": "volts, nil if mains powered", + "type": "number" + }, + "brokers": { + "description": "broker names this observer has been seen on", + "type": "array", + "items": { + "$ref": "#/definitions/api.ObserverBroker" + } + }, + "displayName": { + "description": "friendly name from /status messages", + "type": "string" + }, + "firmwareBuild": { + "type": "string" + }, + "firmwareVersion": { + "type": "string" + }, + "firstSeen": { + "description": "epoch ms", + "type": "integer" + }, + "hardwareModel": { + "type": "string" + }, + "iata": { + "description": "most recently heard IATA", + "type": "string" + }, + "id": { + "type": "string" + }, + "lastSeen": { + "description": "epoch ms", + "type": "integer" + }, + "lastStatusAt": { + "description": "epoch ms", + "type": "integer" + }, + "observationCount": { + "type": "integer" + }, + "observerType": { + "description": "e.g. \"meshcoretomqtt\", \"meshcoreha\"", + "type": "string" + }, + "publicKey": { + "description": "hex-encoded public key", + "type": "string" + }, + "radioBwKhz": { + "description": "bandwidth in kHz", + "type": "number" + }, + "radioCr": { + "description": "coding rate denominator", + "type": "integer" + }, + "radioFreqMhz": { + "description": "MHz e.g. 910.525", + "type": "number" + }, + "radioSf": { + "description": "LoRa spreading factor", + "type": "integer" + }, + "softwareVersion": { + "type": "string" + }, + "status": { + "description": "\"online\" or \"offline\" derived from last_status_at", + "type": "string" + }, + "statusMetadata": { + "description": "raw /status JSON payload" + }, + "uptimeSeconds": { + "type": "integer" + } + } + }, + "api.ObserverBroker": { + "type": "object", + "properties": { + "lastPacketAt": { + "description": "epoch ms, last packet received via this broker; 0 if none", + "type": "integer" + }, + "lastSeenAt": { + "description": "epoch ms, last time observer was seen on this broker", + "type": "integer" + }, + "name": { + "description": "broker name e.g. \"mqtt1\"", + "type": "string" + } + } + }, + "api.ObserverTelemetry": { + "type": "object", + "properties": { + "interval": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ObserverTelemetryPoint" + } + }, + "range": { + "type": "string" + } + } + }, + "api.ObserverTelemetryPoint": { + "type": "object", + "properties": { + "airtimeRxPct": { + "type": "number" + }, + "airtimeTxPct": { + "type": "number" + }, + "batteryMv": { + "type": "integer" + }, + "noiseFloorDb": { + "type": "number" + }, + "queueLength": { + "type": "integer" + }, + "receiveErrors": { + "type": "integer" + }, + "t": { + "description": "epoch ms", + "type": "integer" + }, + "uptimeSeconds": { + "type": "integer" + } + } + }, + "api.Packet": { + "type": "object", + "properties": { + "channelHash": { + "description": "hex-encoded", + "type": "string" + }, + "decrypted": { + "type": "boolean" + }, + "firstHeardAt": { + "description": "epoch ms", + "type": "integer" + }, + "lastHeardAt": { + "description": "epoch ms", + "type": "integer" + }, + "observationCount": { + "type": "integer" + }, + "observations": { + "type": "array", + "items": { + "$ref": "#/definitions/api.PacketObservationDetail" + } + }, + "originPubkey": { + "description": "hex-encoded", + "type": "string" + }, + "packetHash": { + "description": "hex-encoded", + "type": "string" + }, + "parsedPayload": {}, + "payloadType": { + "type": "integer" + }, + "payloadTypeName": { + "type": "string" + }, + "payloadVersion": { + "type": "integer" + }, + "rawPayload": { + "description": "hex-encoded", + "type": "string" + }, + "routeType": { + "type": "integer" + }, + "routeTypeName": { + "type": "string" + }, + "transportCodes": { + "description": "hex-encoded", + "type": "string" + } + } + }, + "api.PacketObservationDetail": { + "type": "object", + "properties": { + "hashSize": { + "type": "integer" + }, + "heardAt": { + "description": "epoch ms", + "type": "integer" + }, + "hopCount": { + "type": "integer" + }, + "iata": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "observerId": { + "type": "string" + }, + "observerName": { + "type": "string" + }, + "pathBytes": { + "description": "hex-encoded", + "type": "string" + }, + "pathLengthByte": { + "type": "integer" + }, + "propagationTimeMs": { + "type": "integer" + }, + "radio": { + "$ref": "#/definitions/api.PacketRadio" + }, + "resolvedPath": { + "type": "array", + "items": { + "$ref": "#/definitions/api.ResolvedHop" + } + }, + "rssi": { + "type": "integer" + }, + "snr": { + "type": "number" + }, + "sourceBroker": { + "type": "string" + } + } + }, + "api.PacketRadio": { + "type": "object", + "properties": { + "bandwidthKhz": { + "type": "number" + }, + "codingRate": { + "type": "integer" + }, + "freqMhz": { + "type": "number" + }, + "spreadFactor": { + "type": "integer" + } + } + }, + "api.PayloadBreakdownItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "payloadType": { + "type": "integer" + }, + "payloadTypeName": { + "type": "string" + } + } + }, + "api.Region": { + "type": "object", + "properties": { + "centerLat": { + "description": "map center latitude", + "type": "number" + }, + "centerLng": { + "description": "map center longitude", + "type": "number" + }, + "description": { + "type": "string" + }, + "iatas": { + "description": "member IATA codes", + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "description": "URL-safe identifier e.g. \"western-canada\"", + "type": "string" + }, + "zoomLevel": { + "description": "suggested map zoom level", + "type": "integer" + } + } + }, + "api.RegionSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "description": "URL-safe identifier e.g. \"western-canada\"", + "type": "string" + } + } + }, + "api.ResolvedHop": { + "type": "object", + "properties": { + "confidence": { + "description": "\"high\", \"low\", \"unknown\"", + "type": "string" + }, + "node": { + "$ref": "#/definitions/api.ResolvedNode" + } + } + }, + "api.ResolvedNode": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "publicKey": { + "description": "hex-encoded prefix", + "type": "string" + } + } + }, + "api.StatsOverview": { + "type": "object", + "properties": { + "activeIatas": { + "type": "integer" + }, + "activeObservers": { + "type": "integer" + }, + "totalObservations": { + "type": "integer" + }, + "totalPackets": { + "type": "integer" + }, + "windowHours": { + "description": "always 24 for now", + "type": "integer" + } + } + }, + "api.TopNode": { + "type": "object", + "properties": { + "iata": { + "type": "string" + }, + "lastHeard": { + "description": "epoch ms", + "type": "integer" + }, + "nodeId": { + "type": "string" + }, + "nodeName": { + "type": "string" + }, + "nodeType": { + "type": "integer" + }, + "nodeTypeName": { + "type": "string" + }, + "observationCount": { + "type": "integer" + } + } + }, + "api.TopObserver": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "iata": { + "type": "string" + }, + "observationCount": { + "type": "integer" + }, + "observerId": { + "type": "string" + }, + "observerType": { + "type": "string" + } + } + }, + "handlers.APIError": { + "type": "object", + "properties": { + "code": { + "description": "e.g. \"not_found\", \"bad_request\"", + "type": "string" + }, + "message": { + "description": "e.g. \"channel not found\"", + "type": "string" + } + } + }, + "handlers.BrokerStatus": { + "type": "object", + "properties": { + "connected": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + } + }, + "tags": [ + { + "description": "Airport/location codes that group observers and packets", + "name": "IATAs" + }, + { + "description": "Super-regions grouping multiple IATAs", + "name": "Regions" + }, + { + "description": "MeshCore MQTT observers (gateways)", + "name": "Observers" + }, + { + "description": "MeshCore radio nodes", + "name": "Nodes" + }, + { + "description": "LoRa packets heard by observers", + "name": "Packets" + }, + { + "description": "MeshCore group text channels", + "name": "Channels" + }, + { + "description": "Decrypted channel messages", + "name": "Messages" + }, + { + "description": "MQTT broker connection status", + "name": "Brokers" + }, + { + "description": "Network statistics and time series", + "name": "Stats" + } + ] +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..04d932a --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,1167 @@ +basePath: /api/v1 +definitions: + api.Channel: + properties: + channelHash: + description: hex-encoded single-byte hash + type: string + hashtag: + description: 'tag name without # prefix' + type: string + id: + type: integer + isHashtag: + description: true if derived from a hashtag PSK + type: boolean + keyFingerprint: + description: first 8 bytes of SHA256(key), hex-encoded + type: string + keyKnown: + description: true if Tower has a decryption key + type: boolean + lastSeen: + description: epoch ms + type: integer + messageCount: + type: integer + name: + description: display name, nil if not set + type: string + type: object + api.IATA: + properties: + displayName: + type: string + iata: + type: string + lat: + type: number + lon: + type: number + type: object + api.Node: + properties: + firstSeen: + description: epoch ms + type: integer + id: + type: string + lastAdvertAt: + description: epoch ms, nil if no advert received + type: integer + lastSeen: + description: epoch ms + type: integer + lat: + type: number + lng: + type: number + locationSource: + description: e.g. "advert", "manual" + type: string + metadata: + description: raw JSONB metadata + minFirmwareVersion: + description: derived from capability flags + type: string + name: + type: string + nodeType: + description: 1=companion, 2=repeater, 3=room server + type: integer + nodeTypeName: + type: string + publicKey: + description: hex-encoded public key + type: string + supportsMultibytePaths: + description: firmware >= 1.14.0 + type: boolean + supportsMultibyteTraces: + description: firmware >= 1.11.0 + type: boolean + type: object + api.ObservationPoint: + properties: + activeObservers: + type: integer + hour: + description: epoch ms, start of bucket + type: integer + iata: + type: string + observationCount: + type: integer + uniquePackets: + type: integer + type: object + api.Observer: + properties: + batteryLevel: + description: volts, nil if mains powered + type: number + brokers: + description: broker names this observer has been seen on + items: + $ref: '#/definitions/api.ObserverBroker' + type: array + displayName: + description: friendly name from /status messages + type: string + firmwareBuild: + type: string + firmwareVersion: + type: string + firstSeen: + description: epoch ms + type: integer + hardwareModel: + type: string + iata: + description: most recently heard IATA + type: string + id: + type: string + lastSeen: + description: epoch ms + type: integer + lastStatusAt: + description: epoch ms + type: integer + observationCount: + type: integer + observerType: + description: e.g. "meshcoretomqtt", "meshcoreha" + type: string + publicKey: + description: hex-encoded public key + type: string + radioBwKhz: + description: bandwidth in kHz + type: number + radioCr: + description: coding rate denominator + type: integer + radioFreqMhz: + description: MHz e.g. 910.525 + type: number + radioSf: + description: LoRa spreading factor + type: integer + softwareVersion: + type: string + status: + description: '"online" or "offline" derived from last_status_at' + type: string + statusMetadata: + description: raw /status JSON payload + uptimeSeconds: + type: integer + type: object + api.ObserverBroker: + properties: + lastPacketAt: + description: epoch ms, last packet received via this broker; 0 if none + type: integer + lastSeenAt: + description: epoch ms, last time observer was seen on this broker + type: integer + name: + description: broker name e.g. "mqtt1" + type: string + type: object + api.ObserverTelemetry: + properties: + interval: + type: string + points: + items: + $ref: '#/definitions/api.ObserverTelemetryPoint' + type: array + range: + type: string + type: object + api.ObserverTelemetryPoint: + properties: + airtimeRxPct: + type: number + airtimeTxPct: + type: number + batteryMv: + type: integer + noiseFloorDb: + type: number + queueLength: + type: integer + receiveErrors: + type: integer + t: + description: epoch ms + type: integer + uptimeSeconds: + type: integer + type: object + api.Packet: + properties: + channelHash: + description: hex-encoded + type: string + decrypted: + type: boolean + firstHeardAt: + description: epoch ms + type: integer + lastHeardAt: + description: epoch ms + type: integer + observationCount: + type: integer + observations: + items: + $ref: '#/definitions/api.PacketObservationDetail' + type: array + originPubkey: + description: hex-encoded + type: string + packetHash: + description: hex-encoded + type: string + parsedPayload: {} + payloadType: + type: integer + payloadTypeName: + type: string + payloadVersion: + type: integer + rawPayload: + description: hex-encoded + type: string + routeType: + type: integer + routeTypeName: + type: string + transportCodes: + description: hex-encoded + type: string + type: object + api.PacketObservationDetail: + properties: + hashSize: + type: integer + heardAt: + description: epoch ms + type: integer + hopCount: + type: integer + iata: + type: string + id: + type: integer + observerId: + type: string + observerName: + type: string + pathBytes: + description: hex-encoded + type: string + pathLengthByte: + type: integer + propagationTimeMs: + type: integer + radio: + $ref: '#/definitions/api.PacketRadio' + resolvedPath: + items: + $ref: '#/definitions/api.ResolvedHop' + type: array + rssi: + type: integer + snr: + type: number + sourceBroker: + type: string + type: object + api.PacketRadio: + properties: + bandwidthKhz: + type: number + codingRate: + type: integer + freqMhz: + type: number + spreadFactor: + type: integer + type: object + api.PayloadBreakdownItem: + properties: + count: + type: integer + payloadType: + type: integer + payloadTypeName: + type: string + type: object + api.Region: + properties: + centerLat: + description: map center latitude + type: number + centerLng: + description: map center longitude + type: number + description: + type: string + iatas: + description: member IATA codes + items: + type: string + type: array + id: + type: integer + name: + type: string + slug: + description: URL-safe identifier e.g. "western-canada" + type: string + zoomLevel: + description: suggested map zoom level + type: integer + type: object + api.RegionSummary: + properties: + id: + type: integer + name: + type: string + slug: + description: URL-safe identifier e.g. "western-canada" + type: string + type: object + api.ResolvedHop: + properties: + confidence: + description: '"high", "low", "unknown"' + type: string + node: + $ref: '#/definitions/api.ResolvedNode' + type: object + api.ResolvedNode: + properties: + id: + type: string + latitude: + type: number + longitude: + type: number + name: + type: string + publicKey: + description: hex-encoded prefix + type: string + type: object + api.StatsOverview: + properties: + activeIatas: + type: integer + activeObservers: + type: integer + totalObservations: + type: integer + totalPackets: + type: integer + windowHours: + description: always 24 for now + type: integer + type: object + api.TopNode: + properties: + iata: + type: string + lastHeard: + description: epoch ms + type: integer + nodeId: + type: string + nodeName: + type: string + nodeType: + type: integer + nodeTypeName: + type: string + observationCount: + type: integer + type: object + api.TopObserver: + properties: + displayName: + type: string + iata: + type: string + observationCount: + type: integer + observerId: + type: string + observerType: + type: string + type: object + handlers.APIError: + properties: + code: + description: e.g. "not_found", "bad_request" + type: string + message: + description: e.g. "channel not found" + type: string + type: object + handlers.BrokerStatus: + properties: + connected: + type: boolean + name: + type: string + type: object +host: localhost:8080 +info: + contact: + name: MeshCore Tower + url: https://github.com/MeshCore-Tower/tower-server + description: MeshCore network observation backend. Ingests LoRa packets from MQTT + brokers, stores in PostgreSQL, and streams live events via WebSocket. + license: + name: MIT + termsOfService: https://github.com/MeshCore-Tower/tower-server + title: MeshCore Tower API + version: "1.0" +paths: + /brokers: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.BrokerStatus' + type: array + summary: List all MQTT brokers and their connection status + tags: + - Brokers + /channels: + get: + parameters: + - description: Single-byte channel hash (hex) + in: query + name: hash + type: string + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: last_seen epoch ms of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List channels + tags: + - Channels + /channels/{channelID}: + get: + parameters: + - description: Channel integer ID + in: path + name: channelID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Channel' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get channel detail + tags: + - Channels + /channels/{channelID}/messages: + get: + parameters: + - description: Channel integer ID + in: path + name: channelID + required: true + type: integer + - description: Return messages after this epoch ms + in: query + name: since + type: integer + - description: Filter by IATA code + in: query + name: iata + type: string + - description: Message ID of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List messages for a channel + tags: + - Channels + /iatas: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.IATA' + type: array + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: List all IATA codes + tags: + - IATAs + /iatas/{iata}: + get: + parameters: + - description: 3-letter IATA code + in: path + name: iata + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.IATA' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get a single IATA code + tags: + - IATAs + /messages: + get: + parameters: + - description: Filter by channel integer ID (mutually exclusive with channelHash) + in: query + name: channelID + type: integer + - description: Filter by channel hash byte hex (mutually exclusive with channelID) + in: query + name: channelHash + type: string + - description: Return messages after this epoch ms + in: query + name: since + type: integer + - description: Filter by IATA code + in: query + name: iata + type: string + - description: Message ID of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List channel messages + tags: + - Messages + /nodes: + get: + parameters: + - description: Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor) + in: query + name: type + type: integer + - description: Node type name (companion, repeater, room_server, sensor) + in: query + name: typeName + type: string + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Partial case-insensitive name match + in: query + name: name + type: string + - description: Exact public key match (hex) + in: query + name: pubkey + type: string + - description: Filter to nodes with firmware >= 1.14.0 + in: query + name: supportsMultibytePaths + type: boolean + - description: Filter to nodes with firmware >= 1.11.0 + in: query + name: supportsMultibyteTraces + type: boolean + - description: last_seen epoch ms of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List nodes + tags: + - Nodes + /nodes/{nodeId}: + get: + parameters: + - description: Node UUID + in: path + name: nodeId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Node' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get node detail + tags: + - Nodes + /nodes/{nodeId}/observations: + get: + parameters: + - description: Node UUID + in: path + name: nodeId + required: true + type: string + - description: Observation ID of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List packet observations originating from a node + tags: + - Nodes + /observers: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Filter by observer type (e.g. meshcoretomqtt, meshcore-ha) + in: query + name: type + type: string + - description: Filter by broker name + in: query + name: broker + type: string + - description: Filter by status (online or offline) + in: query + name: status + type: string + - description: Partial case-insensitive display name match + in: query + name: name + type: string + - description: last_seen epoch ms of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List observers + tags: + - Observers + /observers/{observerId}: + get: + parameters: + - description: Observer UUID + in: path + name: observerId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Observer' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get observer detail + tags: + - Observers + /observers/{observerId}/adverts: + get: + parameters: + - description: Observer UUID + in: path + name: observerId + required: true + type: string + - description: Observation ID of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List advert packets heard by an observer + tags: + - Observers + /observers/{observerId}/telemetry: + get: + parameters: + - description: Observer UUID + in: path + name: observerId + required: true + type: string + - description: Duration window e.g. 24h, 48h, 168h (default 24h) + in: query + name: range + type: string + - description: Return points after this telemetry ID for WS reconnection backfill + in: query + name: afterId + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ObserverTelemetry' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get observer telemetry history + tags: + - Observers + /packets: + get: + parameters: + - description: Filter by payload type integer + in: query + name: payloadType + type: integer + - description: Filter by payload type name (advert, grp_txt, txt_msg, trace, + anon_req) + in: query + name: payloadTypeName + type: string + - description: Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct) + in: query + name: routeType + type: integer + - description: Filter by latest observation IATA (case-insensitive) + in: query + name: iata + type: string + - description: Filter by first_heard_at >= since (epoch ms) + in: query + name: since + type: integer + - description: Filter by first_heard_at <= until (epoch ms) + in: query + name: until + type: integer + - description: last_heard_at epoch ms of last item for pagination + in: query + name: cursor + type: integer + - description: Max results (default 50) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: List packets + tags: + - Packets + /packets/{packetHash}: + get: + parameters: + - description: Packet hash (hex) + in: path + name: packetHash + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Packet' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get full packet detail + tags: + - Packets + /regions: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.RegionSummary' + type: array + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: List all regions + tags: + - Regions + /regions/{regionId}: + get: + parameters: + - description: Region ID + in: path + name: regionId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.Region' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.APIError' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.APIError' + summary: Get a single region + tags: + - Regions + /stats/observations: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Start of window epoch ms (default 7 days ago) + in: query + name: since + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.ObservationPoint' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Hourly observation time series + tags: + - Stats + /stats/overview: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.StatsOverview' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Network overview stats (last 24h) + tags: + - Stats + /stats/payload-breakdown: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Start of window epoch ms (default last 24h) + in: query + name: since + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.PayloadBreakdownItem' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Observation counts by payload type (last 24h by default) + tags: + - Stats + /stats/top-nodes: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Max results (default 10) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.TopNode' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Top N nodes by observation count (from materialized view) + tags: + - Stats + /stats/top-observers: + get: + parameters: + - description: Filter by IATA code (case-insensitive) + in: query + name: iata + type: string + - description: Start of window epoch ms (default last 24h) + in: query + name: since + type: integer + - description: Max results (default 10) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/api.TopObserver' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.APIError' + summary: Top N observers by observation count (last 24h by default) + tags: + - Stats +schemes: +- http +- https +swagger: "2.0" +tags: +- description: Airport/location codes that group observers and packets + name: IATAs +- description: Super-regions grouping multiple IATAs + name: Regions +- description: MeshCore MQTT observers (gateways) + name: Observers +- description: MeshCore radio nodes + name: Nodes +- description: LoRa packets heard by observers + name: Packets +- description: MeshCore group text channels + name: Channels +- description: Decrypted channel messages + name: Messages +- description: MQTT broker connection status + name: Brokers +- description: Network statistics and time series + name: Stats diff --git a/go.mod b/go.mod index bf29254..cbf5178 100644 --- a/go.mod +++ b/go.mod @@ -10,19 +10,31 @@ require ( github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 github.com/meshcore-go/meshcore-go v1.0.6 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.6 gopkg.in/yaml.v3 v3.0.1 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 4ee6d72..37c91b6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -10,6 +12,18 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -24,32 +38,65 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/meshcore-go/meshcore-go v1.0.6 h1:Vf/DC2vdr76lW/kF4TntjiUgBObAip8fdrwV10R6o0M= github.com/meshcore-go/meshcore-go v1.0.6/go.mod h1:u+Lvlg4Wy4blqCAJB+yGL3j2y7JRow+RQfQPjiI294Y= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers/brokers.go b/internal/api/handlers/brokers.go index a770399..aa00dbb 100644 --- a/internal/api/handlers/brokers.go +++ b/internal/api/handlers/brokers.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" ) +// BrokerStatus is the response shape for a single MQTT broker. type BrokerStatus struct { Name string `json:"name"` Connected bool `json:"connected"` @@ -14,17 +15,25 @@ type BrokerStatus struct { // BrokersRouter mounts all /brokers routes onto a subrouter. // -// GET /brokers → ListBrokers +// GET /brokers → listBrokers // // Note: broker configuration is managed via the server config // file, not the API (v1). These endpoints are read-only. func BrokersRouter(workers []*ingest.Worker) http.Handler { r := chi.NewRouter() + r.Get("/", listBrokers(workers)) + return r +} - // GET /brokers → ListBrokers - // - // Returns all configured brokers and their connection status - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listBrokers godoc +// +// @Summary List all MQTT brokers and their connection status +// @Tags Brokers +// @Produce json +// @Success 200 {array} handlers.BrokerStatus +// @Router /brokers [get] +func listBrokers(workers []*ingest.Worker) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { brokers := make([]BrokerStatus, len(workers)) for i, v := range workers { brokers[i] = BrokerStatus{ @@ -33,6 +42,5 @@ func BrokersRouter(workers []*ingest.Worker) http.Handler { } } respond(w, http.StatusOK, brokers) - }) - return r + } } diff --git a/internal/api/handlers/channels.go b/internal/api/handlers/channels.go index 4bb8c90..1b3dd77 100644 --- a/internal/api/handlers/channels.go +++ b/internal/api/handlers/channels.go @@ -12,21 +12,34 @@ import ( // ChannelsRouter mounts all /channels routes onto a subrouter. // -// GET /channels → ListChannels -// GET /channels/{channelID} → GetChannel -// GET /channels/{channelID}/messages → ListChannelMessages +// GET /channels → listChannels +// GET /channels/{channelID} → getChannel +// GET /channels/{channelID}/messages → listChannelMessages func ChannelsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listChannels(reader)) + r.Route("/{channelID}", func(r chi.Router) { + r.Get("/", getChannel(reader)) + r.Get("/messages", listChannelMessages(reader)) + }) + return r +} - // GET /api/v1/channels - // - // Query params (all optional): - // - // hash= filter by single-byte channel hash - // iata= filter by IATA code (channels with messages heard in that IATA) - // cursor= last_seen epoch ms of last item for pagination - // limit=50 - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listChannels godoc +// +// @Summary List channels +// @Tags Channels +// @Produce json +// @Param hash query string false "Single-byte channel hash (hex)" +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param cursor query int false "last_seen epoch ms of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /channels [get] +func listChannels(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { var limit int64 = 50 if limitParam := r.URL.Query().Get("limit"); limitParam != "" { l, err := strconv.ParseInt(limitParam, 10, 32) @@ -65,87 +78,98 @@ func ChannelsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, channels) - }) - - r.Route("/{channelID}", func(r chi.Router) { - // GET /api/v1/channels/{channelID} - // - // Returns channel detail including key for hashtag channels and message count. - // Other channel keys are server-side config; key material is never exposed via the API. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - var id int64 - if channelID := chi.URLParam(r, "channelID"); channelID != "" { - i, err := strconv.ParseInt(channelID, 10, 32) - if err != nil { - respondError(w, http.StatusBadRequest, "channelID should be an int 32") - return - } - id = i - } - channel, err := reader.GetChannel(r.Context(), int32(id)) - if err != nil { - respondError(w, http.StatusNotFound, "channel not found") - return - } - respond(w, http.StatusOK, channel) - }) - // GET /api/v1/channels/{channelID}/messages - // - // Query params (all optional): - // - // since= return messages after this timestamp - // iata= filter by IATA code - // cursor= message ID of last item for pagination - // limit=50 - // - // Returns paginated decrypted channel messages. - r.Get("/messages", func(w http.ResponseWriter, r *http.Request) { - var id int64 - if channelID := chi.URLParam(r, "channelID"); channelID != "" { - i, err := strconv.ParseInt(channelID, 10, 32) - if err != nil { - respondError(w, http.StatusBadRequest, "channelID should be an int 32") - return - } - id = i - } - var limit int64 = 50 - if limitParam := r.URL.Query().Get("limit"); limitParam != "" { - l, err := strconv.ParseInt(limitParam, 10, 32) - if err != nil { - respondError(w, http.StatusBadRequest, "limit must be an integer") - return - } - limit = l - } - var since time.Time - if sinceParam := r.URL.Query().Get("since"); sinceParam != "" { - ms, err := strconv.ParseInt(sinceParam, 10, 64) - if err != nil { - respondError(w, http.StatusBadRequest, "since must be epoch milliseconds") - return - } - since = time.UnixMilli(ms) - } - iata := r.URL.Query().Get("iata") - var cursor int64 - if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { - c, err := strconv.ParseInt(cursorParam, 10, 64) - if err != nil { - respondError(w, http.StatusBadRequest, "cursor must be an integer") - return - } - cursor = c - } - chanID := int32(id) - messages, err := reader.ListChannelMessages(r.Context(), &chanID, since, int32(limit), iata, cursor) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respond(w, http.StatusOK, messages) - }) - }) - - return r + } +} + +// getChannel godoc +// +// @Summary Get channel detail +// @Tags Channels +// @Produce json +// @Param channelID path int true "Channel integer ID" +// @Success 200 {object} api.Channel +// @Failure 400 {object} handlers.APIError +// @Failure 404 {object} handlers.APIError +// @Router /channels/{channelID} [get] +func getChannel(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var id int64 + if channelID := chi.URLParam(r, "channelID"); channelID != "" { + i, err := strconv.ParseInt(channelID, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "channelID should be an int 32") + return + } + id = i + } + channel, err := reader.GetChannel(r.Context(), int32(id)) + if err != nil { + respondError(w, http.StatusNotFound, "channel not found") + return + } + respond(w, http.StatusOK, channel) + } +} + +// listChannelMessages godoc +// +// @Summary List messages for a channel +// @Tags Channels +// @Produce json +// @Param channelID path int true "Channel integer ID" +// @Param since query int false "Return messages after this epoch ms" +// @Param iata query string false "Filter by IATA code" +// @Param cursor query int false "Message ID of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /channels/{channelID}/messages [get] +func listChannelMessages(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var id int64 + if channelID := chi.URLParam(r, "channelID"); channelID != "" { + i, err := strconv.ParseInt(channelID, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "channelID should be an int 32") + return + } + id = i + } + var limit int64 = 50 + if limitParam := r.URL.Query().Get("limit"); limitParam != "" { + l, err := strconv.ParseInt(limitParam, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "limit must be an integer") + return + } + limit = l + } + var since time.Time + if sinceParam := r.URL.Query().Get("since"); sinceParam != "" { + ms, err := strconv.ParseInt(sinceParam, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "since must be epoch milliseconds") + return + } + since = time.UnixMilli(ms) + } + iata := r.URL.Query().Get("iata") + var cursor int64 + if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { + c, err := strconv.ParseInt(cursorParam, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "cursor must be an integer") + return + } + cursor = c + } + chanID := int32(id) + messages, err := reader.ListChannelMessages(r.Context(), &chanID, since, int32(limit), iata, cursor) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, messages) + } } diff --git a/internal/api/handlers/iatas.go b/internal/api/handlers/iatas.go index e39c1a0..c75f080 100644 --- a/internal/api/handlers/iatas.go +++ b/internal/api/handlers/iatas.go @@ -4,35 +4,50 @@ import ( "net/http" "github.com/MeshCore-Tower/tower-server/internal/api" - "github.com/go-chi/chi/v5" ) // IATAsRouter mounts all /iatas routes onto a subrouter. // -// GET /iatas → ListIATAs -// GET /iatas/{iata} → GetIATA +// GET /iatas → listIATAs +// GET /iatas/{iata} → getIATA func IATAsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listIATAs(reader)) + r.Get("/{iata}", getIATA(reader)) + return r +} - // GET /iatas → ListIATAs - // - // Returns all known IATA codes with display name and coordinates where set. - // IATAs are auto-created on first packet arrival; config file overrides name/coords. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listIATAs godoc +// +// @Summary List all IATA codes +// @Tags IATAs +// @Produce json +// @Success 200 {array} api.IATA +// @Failure 404 {object} handlers.APIError +// @Router /iatas [get] +func listIATAs(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iatas, err := reader.ListIATAs(r.Context()) if err != nil { respondError(w, http.StatusNotFound, "no IATAs found") return } respond(w, http.StatusOK, iatas) - }) + } +} - // GET /iatas/{iata} → GetIATA - // - // Returns detail for a single IATA code including associated region memberships - // and basic recent stats. - r.Get("/{iata}", func(w http.ResponseWriter, r *http.Request) { +// getIATA godoc +// +// @Summary Get a single IATA code +// @Tags IATAs +// @Produce json +// @Param iata path string true "3-letter IATA code" +// @Success 200 {object} api.IATA +// @Failure 404 {object} handlers.APIError +// @Router /iatas/{iata} [get] +func getIATA(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := chi.URLParam(r, "iata") result, err := reader.GetIATA(r.Context(), iata) if err != nil { @@ -40,7 +55,5 @@ func IATAsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, result) - }) - - return r + } } diff --git a/internal/api/handlers/messages.go b/internal/api/handlers/messages.go index b4c20e9..3a7cc0c 100644 --- a/internal/api/handlers/messages.go +++ b/internal/api/handlers/messages.go @@ -12,25 +12,30 @@ import ( // MessagesRouter mounts all /messages routes onto a subrouter. // -// GET /messages → ListMessages +// GET /messages → listMessages func MessagesRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listMessages(reader)) + return r +} - // GET /api/v1/messages - // - // Query params (all optional): - // - // since= return messages after this timestamp - // iata= filter by IATA code - // cursor= message ID of last item for pagination - // limit=50 - // - // Mutually exclusive — provide one or neither, not both: - // - // channelId= filter by channel integer ID - // channelHash= filter by channel hash byte - // - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listMessages godoc +// +// @Summary List channel messages +// @Tags Messages +// @Produce json +// @Param channelID query int false "Filter by channel integer ID (mutually exclusive with channelHash)" +// @Param channelHash query string false "Filter by channel hash byte hex (mutually exclusive with channelID)" +// @Param since query int false "Return messages after this epoch ms" +// @Param iata query string false "Filter by IATA code" +// @Param cursor query int false "Message ID of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /messages [get] +func listMessages(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { channelIDParam := r.URL.Query().Get("channelID") channelHashParam := r.URL.Query().Get("channelHash") if channelIDParam != "" && channelHashParam != "" { @@ -99,7 +104,5 @@ func MessagesRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, messages) - }) - - return r + } } diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 78e049e..d3735ac 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -12,26 +12,38 @@ import ( // NodesRouter mounts all /nodes routes onto a subrouter. // -// GET /nodes → ListNodes -// GET /nodes/{nodeId} → GetNode -// GET /nodes/{nodeId}/observations → ListNodeObservations +// GET /nodes → listNodes +// GET /nodes/{nodeId} → getNode +// GET /nodes/{nodeId}/observations → listNodeObservations func NodesRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listNodes(reader)) + r.Route("/{nodeId}", func(r chi.Router) { + r.Get("/", getNode(reader)) + r.Get("/observations", listNodeObservations(reader)) + }) + return r +} - // GET /api/v1/nodes - // - // Query params (all optional): - // - // type= node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor) - // typeName= node type name (companion, repeater, room_server, sensor) - // iata= filter by IATA code (case-insensitive) - // name= partial case-insensitive name match - // pubkey= exact public key match - // supportsMultibytePaths=true filter to nodes with firmware >= 1.14.0 - // supportsMultibyteTraces=true filter to nodes with firmware >= 1.11.0 - // cursor= last_seen epoch ms of last item for pagination - // limit=50 - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listNodes godoc +// +// @Summary List nodes +// @Tags Nodes +// @Produce json +// @Param type query int false "Node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)" +// @Param typeName query string false "Node type name (companion, repeater, room_server, sensor)" +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param name query string false "Partial case-insensitive name match" +// @Param pubkey query string false "Exact public key match (hex)" +// @Param supportsMultibytePaths query bool false "Filter to nodes with firmware >= 1.14.0" +// @Param supportsMultibyteTraces query bool false "Filter to nodes with firmware >= 1.11.0" +// @Param cursor query int false "last_seen epoch ms of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 500 {object} handlers.APIError +// @Router /nodes [get] +func listNodes(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { var nodeType int16 if typeParam := r.URL.Query().Get("type"); typeParam != "" { t, err := strconv.ParseInt(typeParam, 10, 16) @@ -43,7 +55,6 @@ func NodesRouter(reader api.Reader) http.Handler { } else if typeName := r.URL.Query().Get("typeName"); typeName != "" { nodeType = api.NodeTypeFromString(typeName) } - var limit int32 = 50 if limitParam := r.URL.Query().Get("limit"); limitParam != "" { l, err := strconv.ParseInt(limitParam, 10, 32) @@ -53,7 +64,6 @@ func NodesRouter(reader api.Reader) http.Handler { } limit = int32(l) } - var cursor int64 if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { c, err := strconv.ParseInt(cursorParam, 10, 64) @@ -63,7 +73,6 @@ func NodesRouter(reader api.Reader) http.Handler { } cursor = c } - var pubkey []byte if pubkeyParam := r.URL.Query().Get("pubkey"); pubkeyParam != "" { b, err := hex.DecodeString(pubkeyParam) @@ -73,74 +82,87 @@ func NodesRouter(reader api.Reader) http.Handler { } pubkey = b } - iata := r.URL.Query().Get("iata") name := r.URL.Query().Get("name") supportsMultibytePaths := r.URL.Query().Get("supportsMultibytePaths") == "true" supportsMultibyteTraces := r.URL.Query().Get("supportsMultibyteTraces") == "true" - nodes, err := reader.ListNodes(r.Context(), nodeType, iata, supportsMultibytePaths, supportsMultibyteTraces, pubkey, name, cursor, limit) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return } respond(w, http.StatusOK, nodes) - }) - - r.Route("/{nodeId}", func(r chi.Router) { - // GET /api/v1/nodes/{nodeId} - // - // Returns full node detail including firmware capability flags, - // location source, first/last seen timestamps, and raw metadata. - // Use the nodes list endpoint with pubkey filter to look up a node by public key. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId")) - if err != nil { - respondError(w, http.StatusBadRequest, "invalid node ID") - return - } - node, err := reader.GetNode(r.Context(), nodeID) - if err != nil { - respondError(w, http.StatusNotFound, "node not found") - return - } - respond(w, http.StatusOK, node) - }) - r.Get("/observations", func(w http.ResponseWriter, r *http.Request) { - nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId")) - if err != nil { - respondError(w, http.StatusBadRequest, "invalid node ID") - return - } - - var cursor int64 - if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { - c, err := strconv.ParseInt(cursorParam, 10, 64) - if err != nil { - respondError(w, http.StatusBadRequest, "cursor must be an integer") - return - } - cursor = c - } - - var limit int32 = 50 - if limitParam := r.URL.Query().Get("limit"); limitParam != "" { - l, err := strconv.ParseInt(limitParam, 10, 32) - if err != nil { - respondError(w, http.StatusBadRequest, "limit must be an integer") - return - } - limit = int32(l) - } - - observations, err := reader.ListNodeObservations(r.Context(), nodeID, cursor, limit) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respond(w, http.StatusOK, observations) - }) - }) - - return r + } +} + +// getNode godoc +// +// @Summary Get node detail +// @Tags Nodes +// @Produce json +// @Param nodeId path string true "Node UUID" +// @Success 200 {object} api.Node +// @Failure 400 {object} handlers.APIError +// @Failure 404 {object} handlers.APIError +// @Router /nodes/{nodeId} [get] +func getNode(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid node ID") + return + } + node, err := reader.GetNode(r.Context(), nodeID) + if err != nil { + respondError(w, http.StatusNotFound, "node not found") + return + } + respond(w, http.StatusOK, node) + } +} + +// listNodeObservations godoc +// +// @Summary List packet observations originating from a node +// @Tags Nodes +// @Produce json +// @Param nodeId path string true "Node UUID" +// @Param cursor query int false "Observation ID of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /nodes/{nodeId}/observations [get] +func listNodeObservations(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid node ID") + return + } + var cursor int64 + if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { + c, err := strconv.ParseInt(cursorParam, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "cursor must be an integer") + return + } + cursor = c + } + var limit int32 = 50 + if limitParam := r.URL.Query().Get("limit"); limitParam != "" { + l, err := strconv.ParseInt(limitParam, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "limit must be an integer") + return + } + limit = int32(l) + } + observations, err := reader.ListNodeObservations(r.Context(), nodeID, cursor, limit) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, observations) + } } diff --git a/internal/api/handlers/observers.go b/internal/api/handlers/observers.go index 79b1177..9d00b1d 100644 --- a/internal/api/handlers/observers.go +++ b/internal/api/handlers/observers.go @@ -12,24 +12,38 @@ import ( // ObserversRouter mounts all /observers routes onto a subrouter. // -// GET /observers → ListObservers -// GET /observers/{observerId} → GetObserver -// GET /observers/{observerId}/telemetry → GetObserverTelemetry -// GET /observers/{observerId}/adverts → ListObserverAdverts +// GET /observers → listObservers +// GET /observers/{observerId} → getObserver +// GET /observers/{observerId}/telemetry → getObserverTelemetry +// GET /observers/{observerId}/adverts → listObserverAdverts func ObserversRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listObservers(reader)) + r.Route("/{observerId}", func(r chi.Router) { + r.Get("/", getObserver(reader)) + r.Get("/adverts", listObserverAdverts(reader)) + r.Get("/telemetry", getObserverTelemetry(reader)) + }) + return r +} - // GET /api/v1/observers - // - // Query params (all optional): - // - // iata=YOW - // type=meshcoretomqtt - // broker=mqtt1 - // status=online - // cursor= last_seen epoch ms of last observer for pagination - // limit=50 - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listObservers godoc +// +// @Summary List observers +// @Tags Observers +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param type query string false "Filter by observer type (e.g. meshcoretomqtt, meshcore-ha)" +// @Param broker query string false "Filter by broker name" +// @Param status query string false "Filter by status (online or offline)" +// @Param name query string false "Partial case-insensitive display name match" +// @Param cursor query int false "last_seen epoch ms of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 500 {object} handlers.APIError +// @Router /observers [get] +func listObservers(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") observerType := r.URL.Query().Get("type") broker := r.URL.Query().Get("broker") @@ -59,120 +73,128 @@ func ObserversRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, observers) - }) - - r.Route("/{observerId}", func(r chi.Router) { - // GET /api/v1/observers/{observerId} - // - // Returns full observer detail including broker badges, type, and recent stats. - // Note: observer_owners data is never exposed via the public API. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - observerID := chi.URLParam(r, "observerId") - id, err := uuid.Parse(observerID) - if err != nil { - respondError(w, http.StatusBadRequest, "falied to parse observer UUID") - return - } - - obs, err := reader.GetObserver(r.Context(), id) - if err != nil { - respondError(w, http.StatusNotFound, "observer not found") - return - } - respond(w, http.StatusOK, obs) - }) - // /api/v1/observers/{observerId}/adverts - // - // Query params (all optional): - // - // limit=50 - // cursor= - - r.Get("/adverts", func(w http.ResponseWriter, r *http.Request) { - observerID, err := uuid.Parse(chi.URLParam(r, "observerId")) - if err != nil { - respondError(w, http.StatusBadRequest, "invalid observer ID") - return - } - - var cursor int64 - if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { - c, err := strconv.ParseInt(cursorParam, 10, 64) - if err != nil { - respondError(w, http.StatusBadRequest, "cursor must be an integer") - return - } - cursor = c - } - - var limit int32 = 50 - if limitParam := r.URL.Query().Get("limit"); limitParam != "" { - l, err := strconv.ParseInt(limitParam, 10, 32) - if err != nil { - respondError(w, http.StatusBadRequest, "limit must be an integer") - return - } - limit = int32(l) - } - - adverts, err := reader.ListObserverAdverts(r.Context(), observerID, cursor, limit) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respond(w, http.StatusOK, adverts) - }) - // GET /api/v1/observers/{observerId}/telemetry - // - // Query params (all optional): - // - // range=24h duration string: 24h, 7d, 30d - // afterId= for deterministic WS reconnection backfill - // - // Returns a time-bucketed array of telemetry points suitable for charting - // (battery, airtime, noise floor, uptime, queue depth, receive errors). - r.Get("/telemetry", func(w http.ResponseWriter, r *http.Request) { - observerID, err := uuid.Parse(chi.URLParam(r, "observerId")) - if err != nil { - respondError(w, http.StatusBadRequest, "invalid observer ID") - return - } - - rangeParam := r.URL.Query().Get("range") - if rangeParam == "" { - rangeParam = "24h" - } - - duration, err := time.ParseDuration(rangeParam) - if err != nil { - respondError(w, http.StatusBadRequest, "invalid range, use e.g. 24h, 48h, 168h") - return - } - - afterID := int64(0) - if afterIDParam := r.URL.Query().Get("afterId"); afterIDParam != "" { - id, err := strconv.ParseInt(afterIDParam, 10, 64) - if err != nil { - respondError(w, http.StatusBadRequest, "afterId must be an integer") - return - } - afterID = id - } - - since := time.Now().Add(-duration) - until := time.Time{} // no upper bound - - telemetry, err := reader.GetObserverTelemetry(r.Context(), observerID, since, until, afterID) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - - telemetry.Range = rangeParam - telemetry.Interval = r.URL.Query().Get("interval") // echoed back, not used server-side yet - respond(w, http.StatusOK, telemetry) - }) - }) - - return r + } +} + +// getObserver godoc +// +// @Summary Get observer detail +// @Tags Observers +// @Produce json +// @Param observerId path string true "Observer UUID" +// @Success 200 {object} api.Observer +// @Failure 400 {object} handlers.APIError +// @Failure 404 {object} handlers.APIError +// @Router /observers/{observerId} [get] +func getObserver(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + observerID := chi.URLParam(r, "observerId") + id, err := uuid.Parse(observerID) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to parse observer UUID") + return + } + obs, err := reader.GetObserver(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "observer not found") + return + } + respond(w, http.StatusOK, obs) + } +} + +// listObserverAdverts godoc +// +// @Summary List advert packets heard by an observer +// @Tags Observers +// @Produce json +// @Param observerId path string true "Observer UUID" +// @Param cursor query int false "Observation ID of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /observers/{observerId}/adverts [get] +func listObserverAdverts(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + observerID, err := uuid.Parse(chi.URLParam(r, "observerId")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid observer ID") + return + } + var cursor int64 + if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { + c, err := strconv.ParseInt(cursorParam, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "cursor must be an integer") + return + } + cursor = c + } + var limit int32 = 50 + if limitParam := r.URL.Query().Get("limit"); limitParam != "" { + l, err := strconv.ParseInt(limitParam, 10, 32) + if err != nil { + respondError(w, http.StatusBadRequest, "limit must be an integer") + return + } + limit = int32(l) + } + adverts, err := reader.ListObserverAdverts(r.Context(), observerID, cursor, limit) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + respond(w, http.StatusOK, adverts) + } +} + +// getObserverTelemetry godoc +// +// @Summary Get observer telemetry history +// @Tags Observers +// @Produce json +// @Param observerId path string true "Observer UUID" +// @Param range query string false "Duration window e.g. 24h, 48h, 168h (default 24h)" +// @Param afterId query int false "Return points after this telemetry ID for WS reconnection backfill" +// @Success 200 {object} api.ObserverTelemetry +// @Failure 400 {object} handlers.APIError +// @Failure 500 {object} handlers.APIError +// @Router /observers/{observerId}/telemetry [get] +func getObserverTelemetry(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + observerID, err := uuid.Parse(chi.URLParam(r, "observerId")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid observer ID") + return + } + rangeParam := r.URL.Query().Get("range") + if rangeParam == "" { + rangeParam = "24h" + } + duration, err := time.ParseDuration(rangeParam) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid range, use e.g. 24h, 48h, 168h") + return + } + afterID := int64(0) + if afterIDParam := r.URL.Query().Get("afterId"); afterIDParam != "" { + id, err := strconv.ParseInt(afterIDParam, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "afterId must be an integer") + return + } + afterID = id + } + since := time.Now().Add(-duration) + until := time.Time{} // no upper bound + telemetry, err := reader.GetObserverTelemetry(r.Context(), observerID, since, until, afterID) + if err != nil { + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + telemetry.Range = rangeParam + telemetry.Interval = r.URL.Query().Get("interval") // echoed back, not used server-side yet + respond(w, http.StatusOK, telemetry) + } } diff --git a/internal/api/handlers/packets.go b/internal/api/handlers/packets.go index d9e8605..24e32a3 100644 --- a/internal/api/handlers/packets.go +++ b/internal/api/handlers/packets.go @@ -12,24 +12,33 @@ import ( // PacketsRouter mounts all /packets routes onto a subrouter. // -// GET /packets → ListPackets -// GET /packets/{packetHash} → GetPacket +// GET /packets → listPackets +// GET /packets/{packetHash} → getPacket func PacketsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listPackets(reader)) + r.Get("/{packetHash}", getPacket(reader)) + return r +} - // GET /api/v1/packets - // - // Query params (all optional): - // - // payloadType= filter by payload type integer - // payloadTypeName= filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req) - // routeType= filter by route type integer (0=transport_flood, 1=flood, 2=direct, 3=transport_direct) - // iata= filter by latest observation IATA (case-insensitive) - // since= filter by first_heard_at >= since - // until= filter by first_heard_at <= until - // cursor= last_heard_at epoch ms of last item for pagination - // limit=50 - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listPackets godoc +// +// @Summary List packets +// @Tags Packets +// @Produce json +// @Param payloadType query int false "Filter by payload type integer" +// @Param payloadTypeName query string false "Filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)" +// @Param routeType query int false "Filter by route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)" +// @Param iata query string false "Filter by latest observation IATA (case-insensitive)" +// @Param since query int false "Filter by first_heard_at >= since (epoch ms)" +// @Param until query int false "Filter by first_heard_at <= until (epoch ms)" +// @Param cursor query int false "last_heard_at epoch ms of last item for pagination" +// @Param limit query int false "Max results (default 50)" +// @Success 200 {object} object +// @Failure 500 {object} handlers.APIError +// @Router /packets [get] +func listPackets(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { var payloadType int16 if p := r.URL.Query().Get("payloadType"); p != "" { t, err := strconv.ParseInt(p, 10, 16) @@ -95,12 +104,21 @@ func PacketsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, packets) - }) + } +} - // GET /api/v1/packets/{packetHash} - // - // Returns full packet detail including all observations and resolved paths. - r.Get("/{packetHash}", func(w http.ResponseWriter, r *http.Request) { +// getPacket godoc +// +// @Summary Get full packet detail +// @Tags Packets +// @Produce json +// @Param packetHash path string true "Packet hash (hex)" +// @Success 200 {object} api.Packet +// @Failure 400 {object} handlers.APIError +// @Failure 404 {object} handlers.APIError +// @Router /packets/{packetHash} [get] +func getPacket(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { hashHex := chi.URLParam(r, "packetHash") hash, err := hex.DecodeString(hashHex) if err != nil { @@ -113,7 +131,5 @@ func PacketsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, packet) - }) - - return r + } } diff --git a/internal/api/handlers/regions.go b/internal/api/handlers/regions.go index 83408a4..12ed939 100644 --- a/internal/api/handlers/regions.go +++ b/internal/api/handlers/regions.go @@ -5,37 +5,54 @@ import ( "strconv" "github.com/MeshCore-Tower/tower-server/internal/api" - "github.com/go-chi/chi/v5" ) // RegionsRouter mounts all /regions routes onto a subrouter. // -// GET /regions → ListRegions -// GET /regions/{regionId} → GetRegion +// GET /regions → listRegions +// GET /regions/{regionId} → getRegion // // Note: region creation and IATA assignment are managed via the server config // file, not the API (v1). These endpoints are read-only. func RegionsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/", listRegions(reader)) + r.Get("/{regionId}", getRegion(reader)) + return r +} - // GET /regions → ListRegions - // - // Returns all super-regions with their associated IATA codes, center - // coordinates, and zoom level for map initialisation. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { +// listRegions godoc +// +// @Summary List all regions +// @Tags Regions +// @Produce json +// @Success 200 {array} api.RegionSummary +// @Failure 404 {object} handlers.APIError +// @Router /regions [get] +func listRegions(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { regions, err := reader.ListRegions(r.Context()) if err != nil { respondError(w, http.StatusNotFound, "no regions found") + return } respond(w, http.StatusOK, regions) - }) + } +} - // GET /regions/{regionId} → GetRegion - // - // Returns detail for a single super-region including its full IATA membership - // list and recent aggregate stats. - r.Get("/{regionId}", func(w http.ResponseWriter, r *http.Request) { +// getRegion godoc +// +// @Summary Get a single region +// @Tags Regions +// @Produce json +// @Param regionId path int true "Region ID" +// @Success 200 {object} api.Region +// @Failure 400 {object} handlers.APIError +// @Failure 404 {object} handlers.APIError +// @Router /regions/{regionId} [get] +func getRegion(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { regionID := chi.URLParam(r, "regionId") regionInt, err := strconv.ParseInt(regionID, 10, 32) if err != nil { @@ -48,7 +65,5 @@ func RegionsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, region) - }) - - return r + } } diff --git a/internal/api/handlers/stats.go b/internal/api/handlers/stats.go index 851d6b7..aa89347 100644 --- a/internal/api/handlers/stats.go +++ b/internal/api/handlers/stats.go @@ -12,23 +12,35 @@ import ( // StatsRouter mounts all /stats routes onto a subrouter. // -// GET /stats/overview → GetStatsOverview -// GET /stats/observations → GetStatsObservations -// GET /stats/payload-breakdown → GetStatsPayloadBreakdown -// GET /stats/top-nodes → GetStatsTopNodes -// GET /stats/top-observers → GetStatsTopObservers +// GET /stats/overview → getStatsOverview +// GET /stats/observations → getStatsObservations +// GET /stats/payload-breakdown → getStatsPayloadBreakdown +// GET /stats/top-nodes → getStatsTopNodes +// GET /stats/top-observers → getStatsTopObservers // // All endpoints accept an optional iata= filter (case-insensitive). // regionId= expansion and comma-separated IATAs are not yet implemented. func StatsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() + r.Get("/overview", getStatsOverview(reader)) + r.Get("/observations", getStatsObservations(reader)) + r.Get("/payload-breakdown", getStatsPayloadBreakdown(reader)) + r.Get("/top-nodes", getStatsTopNodes(reader)) + r.Get("/top-observers", getStatsTopObservers(reader)) + return r +} - // GET /api/v1/stats/overview - // - // Query params (all optional): - // - // iata= filter to a single IATA (case-insensitive) - r.Get("/overview", func(w http.ResponseWriter, r *http.Request) { +// getStatsOverview godoc +// +// @Summary Network overview stats (last 24h) +// @Tags Stats +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Success 200 {object} api.StatsOverview +// @Failure 500 {object} handlers.APIError +// @Router /stats/overview [get] +func getStatsOverview(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") overview, err := reader.GetStatsOverview(r.Context(), iata) if err != nil { @@ -37,15 +49,21 @@ func StatsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, overview) - }) + } +} - // GET /api/v1/stats/observations - // - // Query params (all optional): - // - // iata= filter to a single IATA (case-insensitive) - // since= start of window; defaults to 7 days ago - r.Get("/observations", func(w http.ResponseWriter, r *http.Request) { +// getStatsObservations godoc +// +// @Summary Hourly observation time series +// @Tags Stats +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param since query int false "Start of window epoch ms (default 7 days ago)" +// @Success 200 {array} api.ObservationPoint +// @Failure 500 {object} handlers.APIError +// @Router /stats/observations [get] +func getStatsObservations(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") var since time.Time if p := r.URL.Query().Get("since"); p != "" { @@ -63,15 +81,21 @@ func StatsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, points) - }) + } +} - // GET /api/v1/stats/payload-breakdown - // - // Query params (all optional): - // - // iata= filter to a single IATA (case-insensitive) - // since= start of window; defaults to last 24h - r.Get("/payload-breakdown", func(w http.ResponseWriter, r *http.Request) { +// getStatsPayloadBreakdown godoc +// +// @Summary Observation counts by payload type (last 24h by default) +// @Tags Stats +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param since query int false "Start of window epoch ms (default last 24h)" +// @Success 200 {array} api.PayloadBreakdownItem +// @Failure 500 {object} handlers.APIError +// @Router /stats/payload-breakdown [get] +func getStatsPayloadBreakdown(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") var since time.Time if p := r.URL.Query().Get("since"); p != "" { @@ -89,15 +113,21 @@ func StatsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, breakdown) - }) + } +} - // GET /api/v1/stats/top-nodes - // - // Query params (all optional): - // - // iata= filter to a single IATA (case-insensitive) - // limit=10 - r.Get("/top-nodes", func(w http.ResponseWriter, r *http.Request) { +// getStatsTopNodes godoc +// +// @Summary Top N nodes by observation count (from materialized view) +// @Tags Stats +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param limit query int false "Max results (default 10)" +// @Success 200 {array} api.TopNode +// @Failure 500 {object} handlers.APIError +// @Router /stats/top-nodes [get] +func getStatsTopNodes(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") var limit int32 = 10 if p := r.URL.Query().Get("limit"); p != "" { @@ -115,16 +145,22 @@ func StatsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, nodes) - }) + } +} - // GET /api/v1/stats/top-observers - // - // Query params (all optional): - // - // iata= filter to a single IATA (case-insensitive) - // since= start of window; defaults to last 24h - // limit=10 - r.Get("/top-observers", func(w http.ResponseWriter, r *http.Request) { +// getStatsTopObservers godoc +// +// @Summary Top N observers by observation count (last 24h by default) +// @Tags Stats +// @Produce json +// @Param iata query string false "Filter by IATA code (case-insensitive)" +// @Param since query int false "Start of window epoch ms (default last 24h)" +// @Param limit query int false "Max results (default 10)" +// @Success 200 {array} api.TopObserver +// @Failure 500 {object} handlers.APIError +// @Router /stats/top-observers [get] +func getStatsTopObservers(reader api.Reader) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { iata := r.URL.Query().Get("iata") var since time.Time if p := r.URL.Query().Get("since"); p != "" { @@ -151,7 +187,5 @@ func StatsRouter(reader api.Reader) http.Handler { return } respond(w, http.StatusOK, observers) - }) - - return r + } } diff --git a/internal/api/reader.go b/internal/api/reader.go index 419765e..edd547c 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -3,7 +3,6 @@ package api import ( "context" - "encoding/json" "time" "github.com/google/uuid" @@ -128,7 +127,7 @@ type Packet struct { RouteTypeName string `json:"routeTypeName"` TransportCodes *string `json:"transportCodes,omitempty"` // hex-encoded OriginPubkey *string `json:"originPubkey,omitempty"` // hex-encoded - ParsedPayload json.RawMessage `json:"parsedPayload,omitempty"` + ParsedPayload any `json:"parsedPayload,omitempty"` RawPayload string `json:"rawPayload"` // hex-encoded Decrypted bool `json:"decrypted"` ChannelHash *string `json:"channelHash,omitempty"` // hex-encoded @@ -195,14 +194,14 @@ type NodeSummary struct { // location source, and timing metadata. type Node struct { NodeSummary - LocationSource *string `json:"locationSource,omitempty"` // e.g. "advert", "manual" - LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received - SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0 - SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0 - MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags - FirstSeen int64 `json:"firstSeen"` // epoch ms - LastSeen int64 `json:"lastSeen"` // epoch ms - Metadata json.RawMessage `json:"metadata,omitempty"` // raw JSONB metadata + LocationSource *string `json:"locationSource,omitempty"` // e.g. "advert", "manual" + LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received + SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0 + SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0 + MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags + FirstSeen int64 `json:"firstSeen"` // epoch ms + LastSeen int64 `json:"lastSeen"` // epoch ms + Metadata any `json:"metadata,omitempty"` // raw JSONB metadata } // ObserverSummary is the minimal observer representation used in list responses. @@ -238,7 +237,7 @@ type Observer struct { RadioCR *int16 `json:"radioCr,omitempty"` // coding rate denominator BatteryLevel *float32 `json:"batteryLevel,omitempty"` // volts, nil if mains powered UptimeSeconds *int64 `json:"uptimeSeconds,omitempty"` - StatusMetadata json.RawMessage `json:"statusMetadata,omitempty"` // raw /status JSON payload + StatusMetadata any `json:"statusMetadata,omitempty"` // raw /status JSON payload LastStatusAt *int64 `json:"lastStatusAt,omitempty"` // epoch ms FirstSeen int64 `json:"firstSeen"` // epoch ms LastSeen int64 `json:"lastSeen"` // epoch ms diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 214b431..6598e64 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -16,6 +16,7 @@ import ( "github.com/MeshCore-Tower/tower-server/internal/hub" "github.com/MeshCore-Tower/tower-server/internal/ingest" "github.com/MeshCore-Tower/tower-server/internal/ws" + httpSwagger "github.com/swaggo/http-swagger" ) // New builds and returns the top-level Chi router. @@ -46,6 +47,15 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker) http.Handler { r.Use(middleware.CleanPath) r.Use(middleware.StripSlashes) + // ── Swagger UI ────────────────────────────────────────────────────────── + r.Get("/swagger", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/swagger/index.html", http.StatusMovedPermanently) + }) + + r.Get("/swagger/*", httpSwagger.Handler( + httpSwagger.URL("/swagger/doc.json"), + )) + // ── WebSocket ──────────────────────────────────────────────────────────── r.Get("/ws", ws.Handler(h))