Skip to content

fix(server): unknown /api/* paths and wrong methods return 200 index.html instead of a JSON 404/405 #233

Closed

Description

@dborup

Relates to #223, #231

Problem

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.

Activity

  1. dborup commented on Oct 5, 2026

    @dborup
    OwnerAuthor

    Plan — CS-MacBook #233

    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:

    router.PathPrefix("/api/").HandlerFunc(apiFallbackHandler(router))

    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:

    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.

  2. dborup commented on Oct 6, 2026

    @dborup
    OwnerAuthor

    Fixed by #266 (merged as 81b13b20).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions