You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(server): unknown /api/* paths and wrong methods return 200 index.html instead of a JSON 404/405 #233
The server registers a catch-all router.PathPrefix("/") with the SPA handler (spaHandler in cmd/server/main.go) after the API routes. Because of that, an unknown /api/* path, or a known API path called with the wrong method, is served index.html with 200:
POST /api/nonexistent -> 200 text/html
GET /api/nonexistent -> 200 text/html
This was observed on staging, and it is how master behaves today. It came up in #231: once POST /api/packets is removed, the bare API router answers 405, but the production wiring answers 200 with the SPA page.
A client that calls a mistyped or removed endpoint gets a success status and an HTML body. It is easy to misread as success, and it hides integration errors.
Proposed fix
Before the SPA catch-all, add an /api/ handler that returns JSON errors:
404 {"error":"Not found"} for unknown paths;
405 with an Allow header for a known path called with the wrong method. Gorilla mux can provide this through MethodNotAllowedHandler and NotFoundHandler on an /api subrouter, or through an explicit PathPrefix("/api/") fallback.
Keep the WebSocket upgrade path and every existing route unchanged. Check that wsOrStatic and any /api/... paths served by other handlers, such as health and metrics, are not caught by the new fallback.
The SPA fallback for non-API paths (#/... deep links and static files) stays as it is.
Acceptance
An unknown GET /api/... returns JSON 404, not HTML.
A known /api path called with an unsupported method returns 405 (or JSON 404 if 405 is not feasible), never 200 HTML. A test locks POST /api/packets in particular.
Every existing API route, the WebSocket and the SPA deep links behave as before. Routes are checked against the served OpenAPI route list.
No new map[string]interface{}: use the existing error response type.
Where the fallback goes:(s *Server) RegisterRoutes in cmd/server/routes.go, as the last statement — not a separate change in main.go. Every r.HandleFunc/r.Handle call in RegisterRoutes already registers an absolute /api/... path on the single top-level *mux.Router that main.go builds (router := mux.NewRouter(); srv.RegisterRoutes(router)); there is no existing /api subrouter to attach NotFoundHandler/MethodNotAllowedHandler to without rewriting every route's path string. So the fix adds one explicit fallback route instead:
registered as the final line of RegisterRoutes, i.e. strictly after every real /api/* route and strictly before main.go registers /ws and the SPA PathPrefix("/") catch-all (those run later, once RegisterRoutes has returned). gorilla/mux tries routes in registration order; a route whose path matches but whose method doesn't is a "keep trying", not a final 405 — that's exactly why a POST /api/packets request currently falls through, past the mismatched GET /api/packets route, all the way to the SPA catch-all. Putting our own PathPrefix("/api/") route right after the real ones intercepts that fall-through before it ever reaches the SPA handler, for every caller of RegisterRoutes (main.go and every test's setupTestServer), without touching main.go or any other route's registration.
404 vs 405:apiFallbackHandler re-walks the router (router.Walk) and, for the incoming request's literal path, checks every other /api/* route's own declared method(s) via route.Match with the method swapped to the route's own — i.e. "would this route's path pattern (including {params}) match this URL at all, regardless of method". This reuses mux's own path/regex matching instead of reimplementing it (same trick buildOpenAPISpec already uses for routes). If that set is non-empty, respond 405 with Allow: <methods> and the existing writeError JSON body; otherwise 404 with writeError. No new map[string]interface{} — writeError already exists in routes.go.
What stays untouched:
/ws is not under /api/, so the new PathPrefix("/api/") never sees it.
Health/metrics/mqtt-status/perf/etc. are all registered as /api/... routes inside RegisterRoutes already, so they match before the fallback is ever reached — unaffected.
The SPA fallback for non-/api paths (/, static assets, #/... deep links) is registered later in main.go and is never reached by /api/* requests once the new route exists — unaffected for everything else.
Docs: add a short 404/405 note to docs/api-spec.md's existing "Error Responses" section, and a one-sentence addendum to the OpenAPI info.description in openapi.go.
Tests (red first): unknown GET /api/doesnotexist → JSON 404; POST /api/packets → 405 with Allow: GET; a full OpenAPI-route sweep (GET /api/spec, then replay every documented method+path) asserting none of them regressed to 404/405; /ws upgrade and SPA deep-link/static-file behavior unchanged. Mutants: fallback removed, fallback registered after the SPA catch-all instead of before, 405 replaced by 200.
Activity
dborup commentedon Oct 5, 2026
Plan — CS-MacBook #233
Where the fallback goes:
(s *Server) RegisterRoutesincmd/server/routes.go, as the last statement — not a separate change inmain.go. Everyr.HandleFunc/r.Handlecall inRegisterRoutesalready registers an absolute/api/...path on the single top-level*mux.Routerthatmain.gobuilds (router := mux.NewRouter(); srv.RegisterRoutes(router)); there is no existing/apisubrouter to attachNotFoundHandler/MethodNotAllowedHandlerto without rewriting every route's path string. So the fix adds one explicit fallback route instead:registered as the final line of
RegisterRoutes, i.e. strictly after every real/api/*route and strictly beforemain.goregisters/wsand the SPAPathPrefix("/")catch-all (those run later, onceRegisterRouteshas returned). gorilla/mux tries routes in registration order; a route whose path matches but whose method doesn't is a "keep trying", not a final 405 — that's exactly why aPOST /api/packetsrequest currently falls through, past the mismatchedGET /api/packetsroute, all the way to the SPA catch-all. Putting our ownPathPrefix("/api/")route right after the real ones intercepts that fall-through before it ever reaches the SPA handler, for every caller ofRegisterRoutes(main.go and every test'ssetupTestServer), without touchingmain.goor any other route's registration.404 vs 405:
apiFallbackHandlerre-walks the router (router.Walk) and, for the incoming request's literal path, checks every other/api/*route's own declared method(s) viaroute.Matchwith the method swapped to the route's own — i.e. "would this route's path pattern (including{params}) match this URL at all, regardless of method". This reuses mux's own path/regex matching instead of reimplementing it (same trickbuildOpenAPISpecalready uses for routes). If that set is non-empty, respond405withAllow: <methods>and the existingwriteErrorJSON body; otherwise404withwriteError. No newmap[string]interface{}—writeErroralready exists inroutes.go.What stays untouched:
/wsis not under/api/, so the newPathPrefix("/api/")never sees it./api/...routes insideRegisterRoutesalready, so they match before the fallback is ever reached — unaffected./apipaths (/, static assets,#/...deep links) is registered later inmain.goand is never reached by/api/*requests once the new route exists — unaffected for everything else.TestPostPacketsRemovedFallsThroughToSPAInProductionRouter(refactor(server): remove the dead POST /api/packets endpoint (#223) #231) currently pins the bug (POST /api/packets→ 200index.html) as "tracked in fix(server): unknown /api/* paths and wrong methods return 200 index.html instead of a JSON 404/405 #233". That assertion will be updated to expect 405, since fixing fix(server): unknown /api/* paths and wrong methods return 200 index.html instead of a JSON 404/405 #233 is exactly what flips it.Docs: add a short 404/405 note to
docs/api-spec.md's existing "Error Responses" section, and a one-sentence addendum to the OpenAPIinfo.descriptioninopenapi.go.Tests (red first): unknown
GET /api/doesnotexist→ JSON 404;POST /api/packets→ 405 withAllow: GET; a full OpenAPI-route sweep (GET/api/spec, then replay every documented method+path) asserting none of them regressed to 404/405;/wsupgrade and SPA deep-link/static-file behavior unchanged. Mutants: fallback removed, fallback registered after the SPA catch-all instead of before, 405 replaced by 200.dborup commentedon Oct 6, 2026
Fixed by #266 (merged as
81b13b20).