diff --git a/internal/flags/memory.go b/internal/flags/memory.go index 6c66823..2228a05 100644 --- a/internal/flags/memory.go +++ b/internal/flags/memory.go @@ -7,16 +7,16 @@ import ( type MemoryRepository struct { mu sync.RWMutex - flags map[string]Flag + flags map[FlagKey]Flag } func NewMemoryRepository() *MemoryRepository { return &MemoryRepository{ - flags: make(map[string]Flag), + flags: make(map[FlagKey]Flag), } } -func (r *MemoryRepository) Get(_ context.Context, key string) (Flag, error) { +func (r *MemoryRepository) Get(_ context.Context, key FlagKey) (Flag, error) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/internal/flags/repository.go b/internal/flags/repository.go index 3e26d68..b50cfa6 100644 --- a/internal/flags/repository.go +++ b/internal/flags/repository.go @@ -11,6 +11,6 @@ var ( ) type Repository interface { - Get(ctx context.Context, key string) (Flag, error) + Get(ctx context.Context, key FlagKey) (Flag, error) Create(ctx context.Context, flag Flag) error } diff --git a/internal/flags/service.go b/internal/flags/service.go index 42c95af..fbd006f 100644 --- a/internal/flags/service.go +++ b/internal/flags/service.go @@ -31,7 +31,7 @@ func (s *Service) Create(ctx context.Context, flag Flag) (Flag, error) { return flag, nil } -func (s *Service) Evaluate(ctx context.Context, key string, evalCtx EvalContext) (EvalResult, error) { +func (s *Service) Evaluate(ctx context.Context, key FlagKey, evalCtx EvalContext) (EvalResult, error) { flag, err := s.repo.Get(ctx, key) if err != nil { return EvalResult{}, err diff --git a/internal/flags/service_test.go b/internal/flags/service_test.go index 9cd29e7..60afb6a 100644 --- a/internal/flags/service_test.go +++ b/internal/flags/service_test.go @@ -26,7 +26,7 @@ func TestService_Create(t *testing.T) { created, err := svc.Create(ctx, input) require.NoError(t, err) - assert.Equal(t, "new-feature", created.Key) + assert.Equal(t, flags.FlagKey("new-feature"), created.Key) assert.False(t, created.UpdatedAt.IsZero()) } @@ -69,7 +69,7 @@ func TestService_Evaluate_ReturnsDefault(t *testing.T) { result, err := svc.Evaluate(ctx, "my-flag", flags.EvalContext{}) require.NoError(t, err) - assert.Equal(t, "my-flag", result.FlagKey) + assert.Equal(t, flags.FlagKey("my-flag"), result.FlagKey) assert.Equal(t, flags.ReasonDefault, result.Reason) assert.Equal(t, flags.FlagBool, result.Value.Kind) assert.True(t, *result.Value.Bool) diff --git a/internal/flags/types.go b/internal/flags/types.go index e89dced..9533096 100644 --- a/internal/flags/types.go +++ b/internal/flags/types.go @@ -2,6 +2,8 @@ package flags import "time" +type FlagKey string + type FlagType string const ( @@ -11,7 +13,7 @@ const ( ) type Flag struct { - Key string + Key FlagKey Type FlagType Enabled bool // global kill switch DefaultValue Value @@ -76,7 +78,7 @@ const ( ) type EvalResult struct { - FlagKey string + FlagKey FlagKey Value Value Reason EvalReason RuleID string diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8868597..3586dc4 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -12,7 +12,7 @@ import ( type FlagService interface { Create(ctx context.Context, flag flags.Flag) (flags.Flag, error) - Evaluate(ctx context.Context, key string, evalCtx flags.EvalContext) (flags.EvalResult, error) + Evaluate(ctx context.Context, key flags.FlagKey, evalCtx flags.EvalContext) (flags.EvalResult, error) } type Handler struct { @@ -44,7 +44,7 @@ func (h *Handler) CreateFlag(ctx context.Context, req *CreateFlagRequest) (*Crea func (h *Handler) EvaluateFlag(ctx context.Context, req *EvaluateFlagRequest) (*EvaluateFlagResponse, error) { evalCtx := ToEvalContext(req.Body) - result, err := h.service.Evaluate(ctx, req.Key, evalCtx) + result, err := h.service.Evaluate(ctx, flags.FlagKey(req.Key), evalCtx) if err != nil { if errors.Is(err, flags.ErrFlagNotFound) { return nil, huma.Error404NotFound("flag not found") diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 7cd2c5a..690bbb4 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -45,7 +45,7 @@ func TestHandler_CreateFlag(t *testing.T) { resp, err := h.CreateFlag(ctx, req) require.NoError(t, err) - assert.Equal(t, "test-flag", resp.Body.Key) + assert.Equal(t, flags.FlagKey("test-flag"), resp.Body.Key) assert.False(t, resp.Body.CreatedAt.IsZero()) } @@ -109,7 +109,7 @@ func TestHandler_EvaluateFlag(t *testing.T) { boolVal := true mockService.EXPECT(). - Evaluate(gomock.Any(), "my-flag", gomock.Any()). + Evaluate(gomock.Any(), flags.FlagKey("my-flag"), gomock.Any()). Return(flags.EvalResult{ FlagKey: "my-flag", Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal}, @@ -128,7 +128,7 @@ func TestHandler_EvaluateFlag(t *testing.T) { resp, err := h.EvaluateFlag(ctx, req) require.NoError(t, err) - assert.Equal(t, "my-flag", resp.Body.FlagKey) + assert.Equal(t, flags.FlagKey("my-flag"), resp.Body.FlagKey) assert.Equal(t, "default", resp.Body.Reason) assert.Equal(t, "bool", resp.Body.Value.Kind) assert.True(t, *resp.Body.Value.Bool) @@ -143,7 +143,7 @@ func TestHandler_EvaluateFlag_NotFound(t *testing.T) { ctx := context.Background() mockService.EXPECT(). - Evaluate(gomock.Any(), "nonexistent", gomock.Any()). + Evaluate(gomock.Any(), flags.FlagKey("nonexistent"), gomock.Any()). Return(flags.EvalResult{}, flags.ErrFlagNotFound) req := &handler.EvaluateFlagRequest{ @@ -166,7 +166,7 @@ func TestHandler_EvaluateFlag_RuleMatch(t *testing.T) { boolVal := true mockService.EXPECT(). - Evaluate(gomock.Any(), "premium-feature", gomock.Any()). + Evaluate(gomock.Any(), flags.FlagKey("premium-feature"), gomock.Any()). Return(flags.EvalResult{ FlagKey: "premium-feature", Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal}, @@ -200,7 +200,7 @@ func TestHandler_EvaluateFlag_Disabled(t *testing.T) { boolVal := false mockService.EXPECT(). - Evaluate(gomock.Any(), "disabled-flag", gomock.Any()). + Evaluate(gomock.Any(), flags.FlagKey("disabled-flag"), gomock.Any()). Return(flags.EvalResult{ FlagKey: "disabled-flag", Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal}, @@ -228,7 +228,7 @@ func TestHandler_EvaluateFlag_InternalError(t *testing.T) { ctx := context.Background() mockService.EXPECT(). - Evaluate(gomock.Any(), "test-flag", gomock.Any()). + Evaluate(gomock.Any(), flags.FlagKey("test-flag"), gomock.Any()). Return(flags.EvalResult{}, errors.New("database connection failed")) req := &handler.EvaluateFlagRequest{ diff --git a/internal/handler/mapper.go b/internal/handler/mapper.go index 4b7b8bf..d073fca 100644 --- a/internal/handler/mapper.go +++ b/internal/handler/mapper.go @@ -6,7 +6,7 @@ import ( func ToFlag(body CreateFlagBody) flags.Flag { return flags.Flag{ - Key: body.Key, + Key: flags.FlagKey(body.Key), Type: flags.FlagType(body.Type), Enabled: body.Enabled, DefaultValue: toValue(body.DefaultValue), diff --git a/internal/handler/mapper_test.go b/internal/handler/mapper_test.go index fc97e02..aec6ff8 100644 --- a/internal/handler/mapper_test.go +++ b/internal/handler/mapper_test.go @@ -37,7 +37,7 @@ func TestToFlag(t *testing.T) { flag := handler.ToFlag(body) - assert.Equal(t, "test-flag", flag.Key) + assert.Equal(t, flags.FlagKey("test-flag"), flag.Key) assert.Equal(t, flags.FlagBool, flag.Type) assert.True(t, flag.Enabled) assert.Equal(t, flags.FlagBool, flag.DefaultValue.Kind) @@ -60,7 +60,7 @@ func TestToFlag_EmptyRules(t *testing.T) { flag := handler.ToFlag(body) - assert.Equal(t, "simple-flag", flag.Key) + assert.Equal(t, flags.FlagKey("simple-flag"), flag.Key) assert.Nil(t, flag.Rules) } @@ -102,7 +102,7 @@ func TestToEvalResultBody(t *testing.T) { body := handler.ToEvalResultBody(result) - assert.Equal(t, "my-flag", body.FlagKey) + assert.Equal(t, flags.FlagKey("my-flag"), body.FlagKey) assert.Equal(t, "bool", body.Value.Kind) assert.True(t, *body.Value.Bool) assert.Equal(t, "rule_match", body.Reason) diff --git a/internal/handler/mock_service_test.go b/internal/handler/mock_service_test.go index 9d0c1e1..78ef28d 100644 --- a/internal/handler/mock_service_test.go +++ b/internal/handler/mock_service_test.go @@ -57,7 +57,7 @@ func (mr *MockFlagServiceMockRecorder) Create(ctx, flag any) *gomock.Call { } // Evaluate mocks base method. -func (m *MockFlagService) Evaluate(ctx context.Context, key string, evalCtx flags.EvalContext) (flags.EvalResult, error) { +func (m *MockFlagService) Evaluate(ctx context.Context, key flags.FlagKey, evalCtx flags.EvalContext) (flags.EvalResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Evaluate", ctx, key, evalCtx) ret0, _ := ret[0].(flags.EvalResult) diff --git a/internal/handler/models.go b/internal/handler/models.go index 5a1135e..39369ae 100644 --- a/internal/handler/models.go +++ b/internal/handler/models.go @@ -2,6 +2,8 @@ package handler import ( "time" + + "github.com/serroba/features/internal/flags" ) // Request/Response models for Create Flag @@ -11,27 +13,27 @@ type CreateFlagRequest struct { } type CreateFlagBody struct { - Key string `json:"key"` - Type string `json:"type"` + Key string `json:"key" maxLength:"128" minLength:"1" pattern:"^[a-z][a-z0-9-]*$"` + Type string `enum:"bool,string,number" json:"type"` Enabled bool `json:"enabled"` DefaultValue ValueBody `json:"defaultValue"` Rules []RuleBody `json:"rules,omitempty"` } type RuleBody struct { - ID string `json:"id"` - Conditions []ConditionBody `json:"conditions"` + ID string `json:"id" maxLength:"64" minLength:"1"` + Conditions []ConditionBody `json:"conditions" minItems:"1"` Value ValueBody `json:"value"` } type ConditionBody struct { - Attr string `json:"attr"` - Op string `json:"op"` + Attr string `json:"attr" maxLength:"64" minLength:"1"` + Op string `enum:"eq,neq,in,not_in,exists,starts_with" json:"op"` Value any `json:"value"` } type ValueBody struct { - Kind string `json:"kind"` + Kind string `enum:"bool,string,number" json:"kind"` Bool *bool `json:"bool,omitempty"` String *string `json:"string,omitempty"` Number *float64 `json:"number,omitempty"` @@ -42,20 +44,20 @@ type CreateFlagResponse struct { } type CreateFlagResponseBody struct { - Key string `json:"key"` - CreatedAt time.Time `json:"createdAt"` + Key flags.FlagKey `json:"key"` + CreatedAt time.Time `json:"createdAt"` } // Request/Response models for Evaluate Flag type EvaluateFlagRequest struct { - Key string `path:"key"` + Key string `maxLength:"128" minLength:"1" path:"key" pattern:"^[a-z][a-z0-9-]*$"` Body EvaluateFlagBody } type EvaluateFlagBody struct { - TenantID string `json:"tenantId,omitempty"` - UserID string `json:"userId,omitempty"` + TenantID string `json:"tenantId,omitempty" maxLength:"128"` + UserID string `json:"userId,omitempty" maxLength:"128"` Attrs map[string]any `json:"attrs,omitempty"` } @@ -64,9 +66,9 @@ type EvaluateFlagResponse struct { } type EvalResultBody struct { - FlagKey string `json:"flagKey"` - Value ValueBody `json:"value"` - Reason string `json:"reason"` - RuleID string `json:"ruleId,omitempty"` - EvaluatedAt time.Time `json:"evaluatedAt"` + FlagKey flags.FlagKey `json:"flagKey"` + Value ValueBody `json:"value"` + Reason string `enum:"disabled,rule_match,default" json:"reason"` + RuleID string `json:"ruleId,omitempty"` + EvaluatedAt time.Time `json:"evaluatedAt"` }