mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-01 16:48:19 +00:00
add swagger
change to named functions in route handlers for swagger docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1766
File diff suppressed because it is too large
Load Diff
+1167
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user