diff --git a/cmd/server/api_fallback.go b/cmd/server/api_fallback.go new file mode 100644 index 000000000..f2f67d3f8 --- /dev/null +++ b/cmd/server/api_fallback.go @@ -0,0 +1,153 @@ +package main + +import ( + "net/http" + "sort" + "strings" + + "github.com/gorilla/mux" +) + +// registerAPIFallback adds a catch-all for /api/* requests that don't match +// any registered route, so an unknown path or a known path called with the +// wrong method gets a JSON error instead of falling through to the SPA's +// index.html (#233). +// +// 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. +// Without this, that fall-through reaches the SPA PathPrefix("/") handler +// registered later in main.go, which matches any method and serves 200 +// index.html — e.g. POST /api/packets hitting the GET-only route. +// +// Called as the last statement of RegisterRoutes, so it sits after every +// real /api/* route (registered earlier in the same call) and before +// main.go registers /ws and the SPA catch-all (registered after +// RegisterRoutes returns). Every caller of RegisterRoutes — main.go and +// every test's setupTestServer — gets the fallback for free. +// +// Bare /api is an API path too, so it gets its own exact-path route; +// PathPrefix("/api") would also swallow SPA paths like /api-docs. +func registerAPIFallback(router *mux.Router) { + h := apiFallbackHandler(router) + router.Path("/api").HandlerFunc(h).Name(apiFallbackRootRouteName) + router.PathPrefix("/api/").HandlerFunc(h).Name(apiFallbackRouteName) +} + +// Route names of the two fallback routes, so apiRoutesShadowedByFallback +// can find them. +const ( + apiFallbackRootRouteName = "api-fallback-root" + apiFallbackRouteName = "api-fallback" +) + +// apiFallbackHandler serves HEAD through the GET route for the same path, +// otherwise responds 405 with an Allow header if the request path matches a +// known /api route under a different method, otherwise 404. Both errors use +// the existing writeError JSON shape. +func apiFallbackHandler(router *mux.Router) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + if h, getReq := getRouteHandler(router, r); h != nil { + h.ServeHTTP(w, getReq) + return + } + } + if allowed := allowedMethodsForPath(router, r); len(allowed) > 0 { + w.Header().Set("Allow", strings.Join(allowed, ", ")) + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeError(w, http.StatusNotFound, "not found") + } +} + +// allowedMethodsForPath walks the router's registered /api/* routes and +// returns the sorted, deduplicated set of methods whose route would match +// r's path if r had been sent with that method. It reuses mux's own route +// matching (path templates, {params}, etc.) rather than reimplementing it — +// the same trick buildOpenAPISpec uses to list routes. +func allowedMethodsForPath(router *mux.Router, r *http.Request) []string { + seen := map[string]bool{} + router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + path, err := route.GetPathTemplate() + if err != nil || !strings.HasPrefix(path, "/api/") { + return nil + } + methods, err := route.GetMethods() + if err != nil { + // Routes without .Methods() — this fallback itself — match any + // method, so they can never contribute an Allow entry. + return nil + } + for _, m := range methods { + if seen[m] { + continue + } + testReq := r.Clone(r.Context()) + testReq.Method = m + var match mux.RouteMatch + if route.Match(testReq, &match) { + seen[m] = true + } + } + return nil + }) + if seen[http.MethodGet] { + // Every GET route also answers HEAD, via apiFallbackHandler. + seen[http.MethodHead] = true + } + out := make([]string, 0, len(seen)) + for m := range seen { + out = append(out, m) + } + sort.Strings(out) + return out +} + +// getRouteHandler returns the handler of the route a GET to r's URL would +// reach, and that GET request with the route's path variables set, or nil if +// only this fallback matches. gorilla/mux's .Methods("GET") does not match +// HEAD, so HEAD on a known route lands here; RFC 9110 §9.3.2 wants it served +// like GET. The handler runs as GET, and net/http drops the body because the +// connection's request is HEAD. The router middleware has already run around +// this fallback, so the route's own handler is called directly, not +// match.Handler or router.ServeHTTP: that would run the middleware twice and +// could re-enter this fallback. +func getRouteHandler(router *mux.Router, r *http.Request) (http.Handler, *http.Request) { + getReq := r.Clone(r.Context()) + getReq.Method = http.MethodGet + var match mux.RouteMatch + if !router.Match(getReq, &match) || match.MatchErr != nil { + return nil, nil + } + if _, err := match.Route.GetMethods(); err != nil { + // A route without .Methods(): this fallback, so no GET route exists. + return nil, nil + } + return match.Route.GetHandler(), mux.SetURLVars(getReq, match.Vars) +} + +// apiRoutesShadowedByFallback returns the path template of every /api route +// registered after the API fallback. mux tries routes in registration order +// and the fallback matches every method and path under /api, so such a +// route is never reached. main checks the production router at startup; +// TestProductionRouterHasNoShadowedAPIRoutes checks newHTTPRouter. +func apiRoutesShadowedByFallback(router *mux.Router) []string { + var shadowed []string + fallbackSeen := false + router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + switch route.GetName() { + case apiFallbackRootRouteName, apiFallbackRouteName: + fallbackSeen = true + return nil + } + if !fallbackSeen { + return nil + } + if tmpl, err := route.GetPathTemplate(); err == nil && (tmpl == "/api" || strings.HasPrefix(tmpl, "/api/")) { + shadowed = append(shadowed, tmpl) + } + return nil + }) + return shadowed +} diff --git a/cmd/server/api_fallback_test.go b/cmd/server/api_fallback_test.go new file mode 100644 index 000000000..1c847fb15 --- /dev/null +++ b/cmd/server/api_fallback_test.go @@ -0,0 +1,413 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// #233: an unknown /api/* path, or a known one called with the wrong +// method, must get a JSON error, never the SPA's 200 index.html. These +// tests build the router the same two ways production code does: +// - setupTestServer: RegisterRoutes only (what most other _test.go files use) +// - productionStyleRouter below: newHTTPRouter, the RegisterRoutes + /ws + +// SPA catch-all composition main.go serves, with a stub index.html. +func productionStyleRouter(t *testing.T, srv *Server) *mux.Router { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("SPA"), 0o644); err != nil { + t.Fatal(err) + } + return newHTTPRouter(srv, NewHub(), dir) +} + +func TestAPIFallbackUnknownPathReturns404JSON(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/this-path-does-not-exist", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("GET /api/this-path-does-not-exist: want 404, got %d (body %q)", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("want application/json content-type, got %q (body %q)", ct, w.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not JSON: %v (body %q)", err, w.Body.String()) + } + if body["error"] == "" { + t.Errorf("expected a non-empty \"error\" field, got %v", body) + } +} + +func TestAPIFallbackUnknownPathInProductionRouterReturns404JSON(t *testing.T) { + srv, _ := setupTestServer(t) + router := productionStyleRouter(t, srv) + + req := httptest.NewRequest("GET", "/api/this-path-does-not-exist", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("GET /api/this-path-does-not-exist: want 404, got %d (body %q)", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("want application/json content-type, got %q", ct) + } + if strings.Contains(strings.ToLower(w.Body.String()), " "x"), keyed by path, with upper-case methods. +func openAPIOperations(t *testing.T, router http.Handler) map[string][]string { + t.Helper() + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/spec", nil)) + if w.Code != http.StatusOK { + t.Fatalf("GET /api/spec: want 200, got %d", w.Code) + } + var spec struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil { + t.Fatal(err) + } + out := map[string][]string{} + for path, ops := range spec.Paths { + testPath := path + for strings.Contains(testPath, "{") { + start := strings.Index(testPath, "{") + end := strings.Index(testPath[start:], "}") + start + testPath = testPath[:start] + "x" + testPath[end+1:] + } + for method := range ops { + out[testPath] = append(out[testPath], strings.ToUpper(method)) + } + } + if len(out) < 20 { + t.Fatalf("expected at least 20 documented paths, got %d", len(out)) + } + return out +} + +// HEAD on a known /api route must keep succeeding (it was 200 on master, +// via the SPA). Every documented path answers HEAD with the status and +// headers GET gets, and no body; where GET itself is 405 (no GET route for +// that URL), HEAD is 405 with the exact Allow set. Some analytics endpoints +// answer 202 until a background compute finishes, so HEAD is compared with +// the GET just before and just after it. Runs over a real listener so the +// HEAD body suppression is net/http's, as in production. +func TestAPIFallbackHeadMatchesGetForEveryOpenAPIRoute(t *testing.T) { + srv, _ := setupTestServer(t) + router := productionStyleRouter(t, srv) + ts := httptest.NewServer(router) + defer ts.Close() + client := &http.Client{Timeout: 30 * time.Second} + + do := func(method, path string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, ts.URL+path, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp + } + // http.Client never surfaces a HEAD body, so read the raw bytes after + // the header block to prove the server sends none. + rawHeadBody := func(path string) int { + t.Helper() + conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), 5*time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + fmt.Fprintf(conn, "HEAD %s HTTP/1.0\r\nHost: test\r\n\r\n", path) + raw, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("HEAD %s (raw): %v", path, err) + } + i := bytes.Index(raw, []byte("\r\n\r\n")) + if i < 0 { + t.Fatalf("HEAD %s (raw): no header terminator in %q", path, raw) + } + return len(raw) - (i + 4) + } + + ops := openAPIOperations(t, router) + served := 0 + for path, methods := range ops { + before := do("GET", path) + head := do("HEAD", path) + after := do("GET", path) + + if before.StatusCode == http.StatusMethodNotAllowed { + if head.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("HEAD %s (GET is 405): want 405, got %d", path, head.StatusCode) + } + assertAllowSet(t, head.Header.Get("Allow"), methods...) + continue + } + get := before + if head.StatusCode != before.StatusCode { + get = after + } + if head.StatusCode != get.StatusCode { + t.Errorf("HEAD %s: status %d (Allow %q), GET status %d then %d", + path, head.StatusCode, head.Header.Get("Allow"), before.StatusCode, after.StatusCode) + continue + } + for _, h := range []string{"Content-Type", "Cache-Control", "Allow"} { + if hv, gv := head.Header.Get(h), get.Header.Get(h); hv != gv { + t.Errorf("HEAD %s: %s %q, GET %s %q", path, h, hv, h, gv) + } + } + if n := rawHeadBody(path); n != 0 { + t.Errorf("HEAD %s: server sent a %d-byte body", path, n) + } + served++ + } + t.Logf("HEAD served like GET on %d of %d documented paths; the rest have no GET route (405)", served, len(ops)) + if served < 20 { + t.Fatalf("only %d documented paths served HEAD like GET, expected at least 20", served) + } +} + +// Bare /api (no trailing slash) is an API path too: JSON 404, not the SPA. +func TestAPIFallbackBareAPIReturns404JSON(t *testing.T) { + srv, bare := setupTestServer(t) + for name, router := range map[string]http.Handler{"api router": bare, "production router": productionStyleRouter(t, srv)} { + for _, method := range []string{"GET", "POST"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(method, "/api", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("%s: %s /api: want 404, got %d (body %q)", name, method, w.Code, w.Body.String()) + continue + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("%s: %s /api: want application/json, got %q (body %q)", name, method, ct, w.Body.String()) + } + } + } +} + +// Paths that only share the "/api" string prefix are not API paths and +// must still reach the SPA. +func TestAPIFallbackPrefixSiblingsStillReachSPA(t *testing.T) { + srv, _ := setupTestServer(t) + router := productionStyleRouter(t, srv) + for _, path := range []string{"/api-docs", "/apifoo", "/apiary", "/apis/x", "/api.json"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != http.StatusOK || w.Body.String() != "SPA" { + t.Errorf("GET %s: want 200 SPA page, got %d %q", path, w.Code, w.Body.String()) + } + } +} + +// The router main.go serves must not register any /api route after the +// fallback: mux tries routes in order and the fallback matches every method +// and path under /api, so such a route would be dead with no error. +func TestProductionRouterHasNoShadowedAPIRoutes(t *testing.T) { + srv, _ := setupTestServer(t) + router := productionStyleRouter(t, srv) + + // The guard is only meaningful if the fallback is there and is the last + // /api route. + var last string + router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + if tmpl, err := route.GetPathTemplate(); err == nil && (tmpl == "/api" || strings.HasPrefix(tmpl, "/api/")) { + last = route.GetName() + } + return nil + }) + if last != apiFallbackRouteName { + t.Fatalf("last /api route in the production router is %q, want the fallback %q", last, apiFallbackRouteName) + } + if shadowed := apiRoutesShadowedByFallback(router); len(shadowed) > 0 { + t.Errorf("/api routes registered after the fallback are unreachable: %v", shadowed) + } +} + +// The guard itself: a late /api route is reported and really is dead; +// a late non-/api route that merely starts with "api" is not reported. +func TestAPIRoutesShadowedByFallbackReportsLateRoutes(t *testing.T) { + srv, _ := setupTestServer(t) + router := mux.NewRouter() + srv.RegisterRoutes(router) + late := func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("late")) } + router.HandleFunc("/api/late", late).Methods("GET") + router.HandleFunc("/api/late/{id}", late) + router.HandleFunc("/apiary-late", late) + + got := apiRoutesShadowedByFallback(router) + if strings.Join(got, " ") != "/api/late /api/late/{id}" { + t.Errorf("shadowed routes: got %v, want [/api/late /api/late/{id}]", got) + } + + // The fallback answers instead of the late handler (with a 405 that + // names GET, since it sees the late route's method). + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/late", nil)) + if w.Code == http.StatusOK || w.Body.String() == "late" { + t.Errorf("GET /api/late: the late handler ran (%d %q); the fallback should shadow it", w.Code, w.Body.String()) + } +} diff --git a/cmd/server/coverage_test.go b/cmd/server/coverage_test.go index 4be9a2845..ed3beff06 100644 --- a/cmd/server/coverage_test.go +++ b/cmd/server/coverage_test.go @@ -1115,14 +1115,20 @@ func TestPerfMiddlewareSlowQuery(t *testing.T) { srv.store = store router := mux.NewRouter() - srv.RegisterRoutes(router) - // Add a slow handler + // Add a slow handler. Must be registered before RegisterRoutes: since + // #233, RegisterRoutes ends with a catch-all PathPrefix("/api/") (any + // method) to turn unmatched /api/* requests into JSON 404/405 instead + // of falling through to the SPA. gorilla/mux matches in registration + // order, so a route added after RegisterRoutes returns would be + // shadowed by that catch-all. router.HandleFunc("/api/test-slow", func(w http.ResponseWriter, r *http.Request) { time.Sleep(110 * time.Millisecond) writeJSON(w, map[string]string{"ok": "true"}) }).Methods("GET") + srv.RegisterRoutes(router) + req := httptest.NewRequest("GET", "/api/test-slow", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) diff --git a/cmd/server/main.go b/cmd/server/main.go index 14221cdc2..c0de39b89 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -365,25 +365,7 @@ func main() { srv := NewServer(database, cfg, hub) srv.configDir = configDir srv.store = store - router := mux.NewRouter() - srv.RegisterRoutes(router) - - // WebSocket endpoint - router.HandleFunc("/ws", hub.ServeWS) - - // Static files + SPA fallback - absPublic, _ := filepath.Abs(publicDir) - if _, err := os.Stat(absPublic); err == nil { - fs := http.FileServer(http.Dir(absPublic)) - router.PathPrefix("/").Handler(wsOrStatic(hub, spaHandler(absPublic, fs))) - log.Printf("[static] serving %s", absPublic) - } else { - log.Printf("[static] directory %s not found — API-only mode", absPublic) - router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

CoreScope

Frontend not found. API available at /api/

`)) - }) - } + router := newHTTPRouter(srv, hub, publicDir) // Start SQLite poller for WebSocket broadcast poller := NewPoller(database, hub, time.Duration(pollMs)*time.Millisecond) @@ -526,6 +508,12 @@ func main() { _ = cfg.IncrementalVacuumPages() // kept reachable for config validation; not used here _ = cfg.NeighborMaxAgeDays() // ditto — owned by ingestor now + // Every route is registered by now. An /api route added after the + // API fallback would never be reached (#233). + if shadowed := apiRoutesShadowedByFallback(router); len(shadowed) > 0 { + log.Fatalf("[server] /api routes registered after the API fallback are unreachable: %v (register them in RegisterRoutes)", shadowed) + } + // Graceful shutdown var handler http.Handler = router if cfg.GZipEnabled() { @@ -648,6 +636,35 @@ func main() { } } +// newHTTPRouter builds the production router: the API routes (ending in the +// /api fallback, see registerAPIFallback), the WebSocket endpoint and the +// static/SPA catch-all, in that order. Add new /api routes inside +// RegisterRoutes: one added to this router afterwards is shadowed by the +// fallback, which apiRoutesShadowedByFallback reports, at startup in main +// and in TestProductionRouterHasNoShadowedAPIRoutes. +func newHTTPRouter(srv *Server, hub *Hub, publicDir string) *mux.Router { + router := mux.NewRouter() + srv.RegisterRoutes(router) + + // WebSocket endpoint + router.HandleFunc("/ws", hub.ServeWS) + + // Static files + SPA fallback + absPublic, _ := filepath.Abs(publicDir) + if _, err := os.Stat(absPublic); err == nil { + fs := http.FileServer(http.Dir(absPublic)) + router.PathPrefix("/").Handler(wsOrStatic(hub, spaHandler(absPublic, fs))) + log.Printf("[static] serving %s", absPublic) + } else { + log.Printf("[static] directory %s not found — API-only mode", absPublic) + router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

CoreScope

Frontend not found. API available at /api/

`)) + }) + } + return router +} + // spaHandler serves static files, falling back to index.html for SPA routes. // It reads index.html once at creation time and replaces the __BUST__ placeholder // with a Unix timestamp so browsers fetch fresh JS/CSS after each server restart. diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 804c9a343..d379bc60a 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -970,7 +970,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} "openapi": "3.0.3", "info": map[string]interface{}{ "title": "CoreScope API", - "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics.", + "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics. An unrecognized /api or /api/* path returns 404; a documented path called with an unsupported method returns 405 with an Allow header. Both are JSON (#233). HEAD is served on every GET path.", "version": version, "license": map[string]interface{}{ "name": "MIT", diff --git a/cmd/server/post_packets_removed_223_test.go b/cmd/server/post_packets_removed_223_test.go index 90d931162..36cf3fe11 100644 --- a/cmd/server/post_packets_removed_223_test.go +++ b/cmd/server/post_packets_removed_223_test.go @@ -90,6 +90,7 @@ func TestPostPacketsRemovedReturns405OnReadOnlyDB(t *testing.T) { if w.Code != http.StatusMethodNotAllowed { t.Fatalf("POST /api/packets: want 405, got %d (body: %q)", w.Code, w.Body.String()) } + assertAllowSet(t, w.Header().Get("Allow"), "GET", "HEAD") if body := strings.ToLower(w.Body.String()); strings.Contains(body, "sqlite") || strings.Contains(body, "readonly") || strings.Contains(body, "insert") { t.Errorf("response leaks database error text: %q", w.Body.String()) } @@ -140,11 +141,14 @@ func TestOpenAPISpecHasNoPostPackets(t *testing.T) { } } -// main.go mounts a catch-all SPA handler after the API routes. With it in -// place, gorilla/mux lets the catch-all win over the method mismatch, so a -// POST to the removed endpoint is served index.html like any other unmatched -// path (pre-existing fallback behaviour, not specific to #223). Pin that it -// is the SPA page, not JSON, and that nothing is written. +// main.go mounts a catch-all SPA handler after the API routes. Before #233, +// gorilla/mux let that catch-all win over the method mismatch, so a POST to +// the removed endpoint was served index.html like any other unmatched path. +// #233 adds a JSON /api/ fallback (registerAPIFallback, api_fallback.go) +// ahead of the SPA catch-all, so this now pins 405 with an Allow header, +// not the SPA page. See api_fallback_test.go for the general-purpose +// coverage; this test keeps the #223/#231 read-only-DB angle (nothing +// written) on this specific endpoint. func TestPostPacketsRemovedFallsThroughToSPAInProductionRouter(t *testing.T) { dbPath, router := readOnlyPacketServer(t) dir := t.TempDir() @@ -155,10 +159,14 @@ func TestPostPacketsRemovedFallsThroughToSPAInProductionRouter(t *testing.T) { before := packetTableCounts(t, dbPath) w := postRemovedPacket(router) - if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") || w.Body.String() != "SPA" { - t.Fatalf("POST /api/packets with SPA fallback: want 200 text/html index.html, got %d %q %q", + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /api/packets: want 405, got %d %q %q", w.Code, w.Header().Get("Content-Type"), w.Body.String()) } + assertAllowSet(t, w.Header().Get("Allow"), "GET", "HEAD") + if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") { + t.Errorf("want application/json content-type, got %q", w.Header().Get("Content-Type")) + } if after := packetTableCounts(t, dbPath); fmt.Sprint(after) != fmt.Sprint(before) { t.Errorf("packet tables changed: before %v, after %v", before, after) } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index ab8b2a50d..bd1d44096 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -428,6 +428,13 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // OpenAPI spec + Swagger UI r.HandleFunc("/api/spec", s.handleOpenAPISpec).Methods("GET") r.HandleFunc("/api/docs", s.handleSwaggerUI).Methods("GET") + + // JSON 404/405 fallback for unmatched /api/* requests (#233). Must be + // the LAST route registered here: every real /api/* route above gets + // first try at matching, and this only catches what none of them did. + // See registerAPIFallback's doc comment (api_fallback.go) for why this + // has to sit here rather than relying on mux's default 404 handling. + registerAPIFallback(r) } // noStoreAPIMiddleware sets Cache-Control: no-store on every response diff --git a/docs/api-spec.md b/docs/api-spec.md index 619e731ab..481d3b72b 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -99,7 +99,10 @@ They return `total` (the unfiltered/filtered count before pagination). ``` - `400` — Bad request (missing/invalid params) -- `404` — Resource not found +- `404` — Resource not found, or an unrecognized `/api` or `/api/*` path +- `405` — A known `/api/*` path called with an unsupported method; the response carries an `Allow` header listing the methods that path does support + +`HEAD` is accepted on every path that accepts `GET` and returns the same status and headers without a body. ---