feat: implement API v2 with feature flag control

- Added /api/v2/greet POST endpoint with JSON request/response

- Implemented ServiceV2 with Hello my friend <name>! greeting format

- Added api.v2_enabled feature flag (default: false)

- Extended BDD tests to cover v2 scenarios

- Maintained full backward compatibility with v1 API

- Added DLC_API_V2_ENABLED environment variable support

- Created ADR 0010-api-v2-feature-flag.md

- Updated configuration system to support API versioning
This commit is contained in:
2026-04-04 20:39:46 +02:00
parent d29d7a221a
commit 875eb09fb7
12 changed files with 453 additions and 3 deletions

View File

@@ -27,6 +27,8 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client) {
ctx.Step(`^I request the health endpoint$`, sc.iRequestTheHealthEndpoint)
ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.theResponseShouldBe)
ctx.Step(`^the server is running$`, sc.theServerIsRunning)
ctx.Step(`^the server is running with v2 enabled$`, sc.theServerIsRunningWithV2Enabled)
ctx.Step(`^I send a POST request to v2 greet with name "([^"]*)"$`, sc.iSendPOSTRequestToV2GreetWithName)
}
func (sc *StepContext) iRequestAGreetingFor(name string) error {
@@ -59,3 +61,33 @@ func (sc *StepContext) theServerIsRunning() error {
// Actually verify the server is running by checking the readiness endpoint
return sc.client.Request("GET", "/api/ready", nil)
}
func (sc *StepContext) theServerIsRunningWithV2Enabled() error {
// Verify the server is running and v2 is enabled by checking v2 endpoint exists
// First check server is running
if err := sc.client.Request("GET", "/api/ready", nil); err != nil {
return err
}
// Check if v2 endpoint is available (should return 405 Method Not Allowed for GET, which means endpoint exists)
// If v2 is disabled, this will return 404
resp, err := sc.client.CustomRequest("GET", "/api/v2/greet", nil)
if err != nil {
return err
}
defer resp.Body.Close()
// If we get 405, v2 is enabled (endpoint exists but doesn't allow GET)
// If we get 404, v2 is disabled
if resp.StatusCode == 404 {
return fmt.Errorf("v2 endpoint not available - v2 feature flag not enabled")
}
return nil
}
func (sc *StepContext) iSendPOSTRequestToV2GreetWithName(name string) error {
// Create JSON request body
requestBody := map[string]string{"name": name}
return sc.client.Request("POST", "/api/v2/greet", requestBody)
}

View File

@@ -1,6 +1,8 @@
package testserver
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -19,12 +21,37 @@ func NewClient(server *Server) *Client {
}
}
func (c *Client) Request(method, path string, body []byte) error {
func (c *Client) Request(method, path string, body interface{}) error {
url := c.server.GetBaseURL() + path
req, err := http.NewRequest(method, url, nil)
var reqBody io.Reader
if body != nil {
// Handle different body types
switch b := body.(type) {
case []byte:
reqBody = bytes.NewReader(b)
case string:
reqBody = strings.NewReader(b)
case map[string]string:
jsonBody, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("failed to marshal JSON body: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
default:
return fmt.Errorf("unsupported body type: %T", body)
}
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set content type for JSON bodies
if body != nil && reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -41,6 +68,53 @@ func (c *Client) Request(method, path string, body []byte) error {
return nil
}
func (c *Client) CustomRequest(method, path string, body interface{}) (*http.Response, error) {
url := c.server.GetBaseURL() + path
var reqBody io.Reader
if body != nil {
// Handle different body types
switch b := body.(type) {
case []byte:
reqBody = bytes.NewReader(b)
case string:
reqBody = strings.NewReader(b)
case map[string]string:
jsonBody, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON body: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
default:
return nil, fmt.Errorf("unsupported body type: %T", body)
}
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set content type for JSON bodies
if body != nil && reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// Don't close the body here - let the caller handle it
c.lastResp = resp
c.lastBody, err = io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return resp, nil
}
func (c *Client) ExpectResponseBody(expected string) error {
if c.lastResp == nil {
return fmt.Errorf("no response received")

View File

@@ -100,5 +100,8 @@ func createTestConfig(port int) *config.Config {
Telemetry: config.TelemetryConfig{
Enabled: false,
},
API: config.APIConfig{
V2Enabled: true, // Enable v2 for testing
},
}
}

View File

@@ -17,6 +17,7 @@ type Config struct {
Shutdown ShutdownConfig `mapstructure:"shutdown"`
Logging LoggingConfig `mapstructure:"logging"`
Telemetry TelemetryConfig `mapstructure:"telemetry"`
API APIConfig `mapstructure:"api"`
}
// ServerConfig holds server-related configuration
@@ -46,6 +47,11 @@ type TelemetryConfig struct {
Sampler SamplerConfig `mapstructure:"sampler"`
}
// APIConfig holds API version configuration
type APIConfig struct {
V2Enabled bool `mapstructure:"v2_enabled"`
}
// SamplerConfig holds tracing sampler configuration
type SamplerConfig struct {
Type string `mapstructure:"type"`
@@ -78,6 +84,9 @@ func LoadConfig() (*Config, error) {
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
v.SetDefault("telemetry.sampler.ratio", 1.0)
// API defaults
v.SetDefault("api.v2_enabled", false)
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
v.SetConfigFile(configFile)
@@ -118,6 +127,9 @@ func LoadConfig() (*Config, error) {
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
// API environment variables
v.BindEnv("api.v2_enabled", "DLC_API_V2_ENABLED")
// Unmarshal into Config struct
var config Config
if err := v.Unmarshal(&config); err != nil {
@@ -145,6 +157,7 @@ func LoadConfig() (*Config, error) {
Str("logging_output", config.Logging.Output).
Bool("telemetry_enabled", config.Telemetry.Enabled).
Str("telemetry_service", config.Telemetry.ServiceName).
Bool("api_v2_enabled", config.API.V2Enabled).
Msg("Configuration loaded")
return &config, nil
@@ -185,6 +198,11 @@ func (c *Config) GetSamplerRatio() float64 {
return c.Telemetry.Sampler.Ratio
}
// GetV2Enabled returns whether v2 API is enabled
func (c *Config) GetV2Enabled() bool {
return c.API.V2Enabled
}
// GetLogLevel returns the logging level
func (c *Config) GetLogLevel() string {
return c.Logging.Level

68
pkg/greet/api_v2.go Normal file
View File

@@ -0,0 +1,68 @@
package greet
import (
"context"
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
type GreeterV2 interface {
GreetV2(ctx context.Context, name string) string
}
type ApiV2Greet interface {
RegisterRoutes(router chi.Router)
}
type apiV2GreetHandler struct {
greeter GreeterV2
}
func NewApiV2GreetHandler(greeter GreeterV2) ApiV2Greet {
return &apiV2GreetHandler{greeter: greeter}
}
func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) {
log.Trace().Msg("Registering v2 greet routes")
router.Post("/", h.handleGreetPost)
log.Trace().Msg("v2 Greet routes registered")
}
type greetRequest struct {
Name string `json:"name"`
}
type greetResponse struct {
Message string `json:"message"`
}
func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Request) {
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, `{"error":"failed to read request body"}`, http.StatusBadRequest)
return
}
// Parse JSON
var req greetRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"invalid JSON format"}`, http.StatusBadRequest)
return
}
// Call service
message := h.greeter.GreetV2(r.Context(), req.Name)
// Write response
h.writeJSONResponse(w, message)
}
func (h *apiV2GreetHandler) writeJSONResponse(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greetResponse{Message: message})
}

24
pkg/greet/greet_v2.go Normal file
View File

@@ -0,0 +1,24 @@
package greet
import (
"context"
"github.com/rs/zerolog/log"
)
type ServiceV2 struct{}
func NewServiceV2() *ServiceV2 {
return &ServiceV2{}
}
// GreetV2 returns a v2 greeting message for the given name.
// If name is empty, it defaults to "friend".
// This is the v2 implementation that returns "Hello my friend <name>!"
func (s *ServiceV2) GreetV2(ctx context.Context, name string) string {
log.Trace().Ctx(ctx).Str("name", name).Msg("GreetV2 function called")
if name == "" {
return "Hello my friend!"
}
return "Hello my friend " + name + "!"
}

View File

@@ -0,0 +1,28 @@
package greet
import (
"context"
"testing"
)
func TestServiceV2_GreetV2(t *testing.T) {
service := NewServiceV2()
tests := []struct {
name string
expected string
}{
{"", "Hello my friend!"},
{"John", "Hello my friend John!"},
{"Alice", "Hello my friend Alice!"},
{" ", "Hello my friend !"}, // spaces are not considered empty
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.GreetV2(context.Background(), tt.name)
if result != tt.expected {
t.Errorf("GreetV2(%q) = %q, want %q", tt.name, result, tt.expected)
}
})
}
}

View File

@@ -56,6 +56,14 @@ func (s *Server) setupRoutes() {
r.Use(s.getAllMiddlewares()...)
s.registerApiV1Routes(r)
})
// 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)
})
}
}
func (s *Server) registerApiV1Routes(r chi.Router) {
@@ -66,6 +74,14 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
})
}
func (s *Server) registerApiV2Routes(r chi.Router) {
greetServiceV2 := greet.NewServiceV2()
greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2)
r.Route("/greet", func(r chi.Router) {
greetHandlerV2.RegisterRoutes(r)
})
}
// getAllMiddlewares returns all middleware including OpenTelemetry if enabled
func (s *Server) getAllMiddlewares() []func(http.Handler) http.Handler {
middlewares := []func(http.Handler) http.Handler{