✨ feat(server): api.v2_enabled hot-reload via middleware gate (ADR-0023 Phase 4) (#56)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #56.
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{
|
||||
|
||||
84
pkg/server/v2_gate_test.go
Normal file
84
pkg/server/v2_gate_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"dance-lessons-coach/pkg/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestV2EnabledGate_BlocksWhenDisabled verifies the ADR-0023 Phase 4
|
||||
// hot-reload security property: when api.v2_enabled is false, ANY request
|
||||
// to /api/v2/* returns 404 with a JSON body, not a 200, not a panic.
|
||||
func TestV2EnabledGate_BlocksWhenDisabled(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.API.V2Enabled = false // explicit, even though it is the zero value
|
||||
s := NewServer(cfg, context.Background())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code, "v2 disabled should 404")
|
||||
assert.Contains(t, w.Body.String(), "v2 API is currently disabled",
|
||||
"response should explain why")
|
||||
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
// TestV2EnabledGate_PassesWhenEnabled verifies the gate lets requests
|
||||
// through to the actual v2 handler when api.v2_enabled is true. We use
|
||||
// a v2 endpoint that exists and responds with a 2xx so we can assert
|
||||
// "got past the gate, hit the handler".
|
||||
func TestV2EnabledGate_PassesWhenEnabled(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.API.V2Enabled = true
|
||||
s := NewServer(cfg, context.Background())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
// 200 = v2 handler executed. Anything other than 404 with the gate's
|
||||
// message proves the gate let the request through.
|
||||
assert.NotEqual(t, http.StatusNotFound, w.Code, "v2 enabled should not return 404 from gate")
|
||||
assert.NotContains(t, w.Body.String(), "v2 API is currently disabled",
|
||||
"gate message must NOT appear when enabled")
|
||||
}
|
||||
|
||||
// TestV2EnabledGate_HotReloadEffect simulates the ADR-0023 Phase 4
|
||||
// scenario: the same Server (same router) sees opposite responses
|
||||
// before and after a config flip — proving the gate reads the live
|
||||
// config rather than a snapshot from setupRoutes.
|
||||
func TestV2EnabledGate_HotReloadEffect(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.API.V2Enabled = false
|
||||
s := NewServer(cfg, context.Background())
|
||||
|
||||
// Round 1: disabled
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"a"}`))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
w1 := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w1, req1)
|
||||
assert.Equal(t, http.StatusNotFound, w1.Code, "round 1 (disabled) should 404")
|
||||
|
||||
// Flip the config. In production, Config.WatchAndApply does this on
|
||||
// file change; here we set the field directly to simulate the result.
|
||||
cfg.API.V2Enabled = true
|
||||
|
||||
// Round 2: enabled — same Server, same router, just the config flipped
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"b"}`))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
w2 := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w2, req2)
|
||||
assert.NotEqual(t, http.StatusNotFound, w2.Code, "round 2 (enabled) should NOT 404")
|
||||
assert.NotContains(t, w2.Body.String(), "v2 API is currently disabled")
|
||||
}
|
||||
Reference in New Issue
Block a user