@@ -69,9 +69,16 @@ type (
6969// the two docs routes take precedence over the authenticated catch-all.
7070func (rt * Router ) apiHandler () http.Handler {
7171 mux := http .NewServeMux ()
72- mux .HandleFunc ("GET /v1/openapi.json" , rt .apiOpenAPI )
73- mux .HandleFunc ("GET /v1/docs" , rt .apiDocs )
74- mux .HandleFunc ("GET /v1/healthz" , rt .apiHealthz )
72+ // Recorded like the authenticated routes below, so the OpenAPI coverage test sees
73+ // the whole /v1 surface and not just the part behind apiAuth.
74+ for pattern , h := range map [string ]http.HandlerFunc {
75+ "GET /v1/openapi.json" : rt .apiOpenAPI ,
76+ "GET /v1/docs" : rt .apiDocs ,
77+ "GET /v1/healthz" : rt .apiHealthz ,
78+ } {
79+ rt .apiRoutes = append (rt .apiRoutes , pattern )
80+ mux .HandleFunc (pattern , h )
81+ }
7582 mux .Handle ("/" , rt .apiAuth (rt .apiMux ()))
7683 return mux
7784}
@@ -103,8 +110,16 @@ func (rt *Router) apiHealthz(w http.ResponseWriter, _ *http.Request) {
103110// caller (apiAuth), so every route here already has a valid key.
104111func (rt * Router ) apiMux () http.Handler {
105112 mux := http .NewServeMux ()
113+ // Every /v1 registration goes through hf so the route table is recorded as it is
114+ // built: TestAPISpecCoversEveryRoute reads it back and fails when an endpoint
115+ // ships without an OpenAPI entry. GET /v1/health had drifted that way — reachable,
116+ // documented in docs/api.md, absent from the generated spec.
117+ hf := func (pattern string , h http.HandlerFunc ) {
118+ rt .apiRoutes = append (rt .apiRoutes , pattern )
119+ mux .HandleFunc (pattern , h )
120+ }
106121 id := func (pattern string , h func (http.ResponseWriter , * http.Request , int64 )) {
107- mux . HandleFunc (pattern , func (w http.ResponseWriter , r * http.Request ) {
122+ hf (pattern , func (w http.ResponseWriter , r * http.Request ) {
108123 v , err := strconv .ParseInt (r .PathValue ("id" ), 10 , 64 )
109124 if err != nil {
110125 writeAPIErr (w , http .StatusBadRequest , "bad_request" , "invalid id" )
@@ -114,11 +129,11 @@ func (rt *Router) apiMux() http.Handler {
114129 })
115130 }
116131
117- mux . HandleFunc ("GET /v1/health" , rt .apiHealth )
132+ hf ("GET /v1/health" , rt .apiHealth )
118133
119- mux . HandleFunc ("GET /v1/users" , rt .apiListUsers )
120- mux . HandleFunc ("POST /v1/users" , rt .apiCreateUser )
121- mux . HandleFunc ("POST /v1/users/bulk" , rt .apiBulkUsers )
134+ hf ("GET /v1/users" , rt .apiListUsers )
135+ hf ("POST /v1/users" , rt .apiCreateUser )
136+ hf ("POST /v1/users/bulk" , rt .apiBulkUsers )
122137 id ("GET /v1/users/{id}" , rt .apiGetUser )
123138 id ("PATCH /v1/users/{id}" , rt .apiPatchUser )
124139 id ("DELETE /v1/users/{id}" , rt .apiDeleteUser )
@@ -128,21 +143,22 @@ func (rt *Router) apiMux() http.Handler {
128143 id ("POST /v1/users/{id}/plan" , rt .apiApplyPlan )
129144 id ("GET /v1/users/{id}/connections" , rt .apiUserConnections )
130145
131- mux . HandleFunc ("GET /v1/billing/providers" , rt .apiListProviders )
132- mux . HandleFunc ("GET /v1/billing/plans" , rt .apiListPlans )
133- mux . HandleFunc ("POST /v1/billing/plans" , rt .apiSavePlan )
146+ hf ("GET /v1/billing/providers" , rt .apiListProviders )
147+ hf ("GET /v1/billing/plans" , rt .apiListPlans )
148+ hf ("POST /v1/billing/plans" , rt .apiSavePlan )
134149 id ("DELETE /v1/billing/plans/{id}" , rt .apiDeletePlan )
135- mux . HandleFunc ("GET /v1/billing/orders" , rt .apiListOrders )
136- mux . HandleFunc ("POST /v1/billing/orders" , rt .apiCreateOrder )
150+ hf ("GET /v1/billing/orders" , rt .apiListOrders )
151+ hf ("POST /v1/billing/orders" , rt .apiCreateOrder )
137152 id ("POST /v1/billing/orders/{id}/confirm" , rt .apiConfirmOrder )
138153 id ("POST /v1/billing/orders/{id}/cancel" , rt .apiCancelOrder )
139154
140- mux .HandleFunc ("GET /v1/stats/series" , rt .apiStatsSeries )
141- mux .HandleFunc ("GET /v1/stats/users" , rt .apiStatsUsers )
155+ hf ("GET /v1/stats/series" , rt .apiStatsSeries )
156+ hf ("GET /v1/stats/nodes" , rt .apiStatsNodes )
157+ hf ("GET /v1/stats/users" , rt .apiStatsUsers )
142158
143- mux . HandleFunc ("GET /v1/summary" , rt .apiSummary )
144- mux . HandleFunc ("GET /v1/system" , rt .apiSystem )
145- mux . HandleFunc ("GET /v1/health/report" , rt .apiHealthReport )
159+ hf ("GET /v1/summary" , rt .apiSummary )
160+ hf ("GET /v1/system" , rt .apiSystem )
161+ hf ("GET /v1/health/report" , rt .apiHealthReport )
146162
147163 // Node mutations over the external API must land in the admin audit trail too —
148164 // the panel's audited-middleware wraps only the panel mux, not this one. idFn adds
@@ -158,10 +174,10 @@ func (rt *Router) apiMux() http.Handler {
158174 }
159175 }
160176 nodeAudit := func (pattern , section string , h http.HandlerFunc ) {
161- mux . HandleFunc (pattern , rt .apiAudited (section , h ))
177+ hf (pattern , rt .apiAudited (section , h ))
162178 }
163179
164- mux . HandleFunc ("GET /v1/nodes" , rt .apiListNodes )
180+ hf ("GET /v1/nodes" , rt .apiListNodes )
165181 nodeAudit ("POST /v1/nodes" , "API · нода добавлена" , rt .apiCreateNode )
166182 id ("GET /v1/nodes/{id}" , rt .apiGetNode )
167183 nodeAudit ("PATCH /v1/nodes/{id}" , "API · нода изменена" , idFn (rt .apiPatchNode ))
@@ -173,7 +189,7 @@ func (rt *Router) apiMux() http.Handler {
173189
174190 // Any unmatched /v1 path (or a wrong method) returns a JSON 404 in-envelope
175191 // rather than the default plain-text one.
176- mux . HandleFunc ("/" , func (w http.ResponseWriter , _ * http.Request ) {
192+ hf ("/" , func (w http.ResponseWriter , _ * http.Request ) {
177193 writeAPIErr (w , http .StatusNotFound , "not_found" , "no such endpoint" )
178194 })
179195 return mux
@@ -312,7 +328,7 @@ func (rt *Router) apiUserView(w http.ResponseWriter, u model.User) {
312328// ---- handlers ----
313329
314330func (rt * Router ) apiHealth (w http.ResponseWriter , _ * http.Request ) {
315- writeAPIData (w , http .StatusOK , map [ string ] any { "status" : "ok" })
331+ writeAPIData (w , http .StatusOK , oaHealthResp { Status : "ok" })
316332}
317333
318334// apiListUsers lists users with optional filtering (?status, ?search) and
@@ -684,6 +700,32 @@ func (rt *Router) apiStatsSeries(w http.ResponseWriter, r *http.Request) {
684700 writeAPIData (w , http .StatusOK , series )
685701}
686702
703+ // apiStatsNodes is the /v1 twin of the panel's per-server split: which server
704+ // carried the period's traffic. Offered here because a caller building their own
705+ // reporting on top of /v1/stats/series would otherwise have no way to break the same
706+ // numbers down by server.
707+ func (rt * Router ) apiStatsNodes (w http.ResponseWriter , r * http.Request ) {
708+ q := r .URL .Query ()
709+ var userID int64
710+ if s := q .Get ("user_id" ); s != "" {
711+ v , err := strconv .ParseInt (s , 10 , 64 )
712+ if err != nil || v < 0 {
713+ writeAPIErr (w , http .StatusBadRequest , "bad_request" , "invalid user_id" )
714+ return
715+ }
716+ userID = v
717+ }
718+ rows , err := rt .mgr .NodeTrafficBreakdown (userID , q .Get ("from" ), q .Get ("to" ))
719+ if err != nil {
720+ writeAPIManagerErr (w , err )
721+ return
722+ }
723+ if rows == nil {
724+ rows = []core.NodeTraffic {}
725+ }
726+ writeAPIData (w , http .StatusOK , rows )
727+ }
728+
687729func (rt * Router ) apiStatsUsers (w http.ResponseWriter , r * http.Request ) {
688730 q := r .URL .Query ()
689731 totals , err := rt .mgr .StatsByUser (q .Get ("from" ), q .Get ("to" ))
0 commit comments