add swagger

change to named functions in route handlers for swagger docs
This commit is contained in:
Enot (ded) Skelly
2026-05-28 15:11:04 -07:00
parent 5bd820e0c1
commit 31bcda03b9
18 changed files with 5489 additions and 450 deletions
+63 -2
View File
@@ -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
+34
View File
@@ -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")
+1786
View File
File diff suppressed because it is too large Load Diff
+1766
View File
File diff suppressed because it is too large Load Diff
+1167
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -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
)
+47
View File
@@ -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=
+15 -7
View File
@@ -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
}
}
+119 -95
View File
@@ -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=<hex> filter by single-byte channel hash
// iata=<code> filter by IATA code (channels with messages heard in that IATA)
// cursor=<int> 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=<epoch ms> return messages after this timestamp
// iata=<code> filter by IATA code
// cursor=<int> 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)
}
}
+30 -17
View File
@@ -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
}
}
+22 -19
View File
@@ -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=<epoch ms> return messages after this timestamp
// iata=<code> filter by IATA code
// cursor=<int> message ID of last item for pagination
// limit=50
//
// Mutually exclusive — provide one or neither, not both:
//
// channelId=<int32> filter by channel integer ID
// channelHash=<hex> 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
}
}
+102 -80
View File
@@ -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=<int> node type integer (1=companion, 2=repeater, 3=room_server, 4=sensor)
// typeName=<string> node type name (companion, repeater, room_server, sensor)
// iata=<code> filter by IATA code (case-insensitive)
// name=<string> partial case-insensitive name match
// pubkey=<hex> 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=<int> 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)
}
}
+153 -131
View File
@@ -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=<int> 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=<opaque>
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=<status id> 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)
}
}
+39 -23
View File
@@ -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=<int> filter by payload type integer
// payloadTypeName=<string> filter by payload type name (advert, grp_txt, txt_msg, trace, anon_req)
// routeType=<int> filter by route type integer (0=transport_flood, 1=flood, 2=direct, 3=transport_direct)
// iata=<code> filter by latest observation IATA (case-insensitive)
// since=<epoch ms> filter by first_heard_at >= since
// until=<epoch ms> filter by first_heard_at <= until
// cursor=<int> 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
}
}
+32 -17
View File
@@ -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
}
}
+81 -47
View File
@@ -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=<code> 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=<code> filter to a single IATA (case-insensitive)
// since=<epoch ms> 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=<code> filter to a single IATA (case-insensitive)
// since=<epoch ms> 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=<code> 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=<code> filter to a single IATA (case-insensitive)
// since=<epoch ms> 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
}
}
+10 -11
View File
@@ -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
+10
View File
@@ -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))