✨ feat(server): api.v2_enabled hot-reload via middleware gate (ADR-0023 Phase 4)
Closes ADR-0023 — all 4 phases now shipped. Final field: api.v2_enabled.
Approach: always-register-with-middleware-gate.
- /api/v2/* routes are now registered UNCONDITIONALLY at startup
- new Server.v2EnabledGate middleware reads the live config on every
request and returns 404 + {"error":"not_found","message":"v2 API is
currently disabled"} when api.v2_enabled is false
- the existing Config.WatchAndApply hot-reload pipeline already keeps
the config struct fresh — no extra plumbing needed
- flag flip takes effect on the NEXT request (not in-flight ones)
- no router rebuild, no restart
Tested via 3 unit tests in pkg/server/v2_gate_test.go:
- blocked-when-disabled: 404 + correct error message + JSON content-type
- passes-when-enabled: NOT 404, gate message ABSENT (handler executed)
- hot-reload-mid-life: same Server, same router, config flipped between
two requests → 404 then 200, proves the gate reads live config
Race detector clean. Full BDD suite green. ADR-0023 status promoted to
"Implemented" (no more parenthetical phase tracking).
The original "deferred" rationale in ADR-0023 listed router refactor as
the cost. Turns out the cost was minimal: ~25 lines of middleware + 3
tests + an `if` block deletion.
This commit is contained in:
@@ -193,13 +193,15 @@ func (s *Server) setupRoutes() {
|
||||
r.Post("/cache/flush", s.handleAdminCacheFlush)
|
||||
})
|
||||
|
||||
// Register v2 routes if enabled
|
||||
if s.config.GetV2Enabled() {
|
||||
s.router.Route("/api/v2", func(r chi.Router) {
|
||||
r.Use(s.getAllMiddlewares()...)
|
||||
s.registerApiV2Routes(r)
|
||||
})
|
||||
}
|
||||
// Register v2 routes ALWAYS (ADR-0023 Phase 4 hot-reload). The
|
||||
// v2EnabledGate middleware checks the live config on every request
|
||||
// and returns 404 when api.v2_enabled is false. This lets the flag
|
||||
// be flipped via config hot-reload without a router rebuild.
|
||||
s.router.Route("/api/v2", func(r chi.Router) {
|
||||
r.Use(s.getAllMiddlewares()...)
|
||||
r.Use(s.v2EnabledGate)
|
||||
s.registerApiV2Routes(r)
|
||||
})
|
||||
|
||||
// Add Swagger UI with embedded spec
|
||||
// Serve the embedded swagger.json file
|
||||
@@ -269,6 +271,25 @@ func (s *Server) registerApiV2Routes(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
// v2EnabledGate is the middleware that gates the /api/v2/* subtree on the
|
||||
// live api.v2_enabled config value (ADR-0023 Phase 4 hot-reload). When
|
||||
// disabled, returns 404 with the same body shape as a missing route would
|
||||
// emit, so clients see "v2 doesn't exist" rather than "v2 is forbidden".
|
||||
//
|
||||
// Flipping the config at runtime via Config.WatchAndApply takes effect on
|
||||
// the next request — no router rebuild, no restart.
|
||||
func (s *Server) v2EnabledGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.config.GetV2Enabled() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not_found","message":"v2 API is currently disabled"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getAllMiddlewares returns all middleware including OpenTelemetry if enabled
|
||||
func (s *Server) getAllMiddlewares() []func(http.Handler) http.Handler {
|
||||
middlewares := []func(http.Handler) http.Handler{
|
||||
|
||||
Reference in New Issue
Block a user