feat(server): api.v2_enabled hot-reload via middleware gate (ADR-0023 Phase 4) (#56)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled

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:
2026-05-05 10:35:03 +02:00
committed by arcodange
parent 9895c159fe
commit de5b599455
4 changed files with 114 additions and 9 deletions

View File

@@ -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{

View 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")
}