Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions internal/flags/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion internal/flags/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion internal/flags/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/flags/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -69,7 +69,7 @@ func TestService_Evaluate_ReturnsDefault(t *testing.T) {
result, err := svc.Evaluate(ctx, "my-flag", flags.EvalContext{})

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Evaluate method now expects a flags.FlagKey type, not a string. Change this to svc.Evaluate(ctx, flags.FlagKey("my-flag"), flags.EvalContext{}) for type consistency.

Copilot uses AI. Check for mistakes.
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)
Expand Down
6 changes: 4 additions & 2 deletions internal/flags/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package flags

import "time"

type FlagKey string

type FlagType string

const (
Expand All @@ -11,7 +13,7 @@ const (
)

type Flag struct {
Key string
Key FlagKey
Type FlagType
Enabled bool // global kill switch
DefaultValue Value
Expand Down Expand Up @@ -76,7 +78,7 @@ const (
)

type EvalResult struct {
FlagKey string
FlagKey FlagKey
Value Value
Reason EvalReason
RuleID string
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
14 changes: 7 additions & 7 deletions internal/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -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",

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FlagKey field should use the flags.FlagKey type for consistency. Change this to FlagKey: flags.FlagKey("my-flag") to match the new type system.

Suggested change
FlagKey: "my-flag",
FlagKey: flags.FlagKey("my-flag"),

Copilot uses AI. Check for mistakes.
Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal},
Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -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",

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FlagKey field should use the flags.FlagKey type for consistency. Change this to FlagKey: flags.FlagKey("premium-feature") to match the new type system.

Suggested change
FlagKey: "premium-feature",
FlagKey: flags.FlagKey("premium-feature"),

Copilot uses AI. Check for mistakes.
Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal},
Expand Down Expand Up @@ -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",

Copilot AI Jan 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FlagKey field should use the flags.FlagKey type for consistency. Change this to FlagKey: flags.FlagKey("disabled-flag") to match the new type system.

Suggested change
FlagKey: "disabled-flag",
FlagKey: flags.FlagKey("disabled-flag"),

Copilot uses AI. Check for mistakes.
Value: flags.Value{Kind: flags.FlagBool, Bool: &boolVal},
Expand Down Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions internal/handler/mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/mock_service_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 19 additions & 17 deletions internal/handler/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package handler

import (
"time"

"github.com/serroba/features/internal/flags"
)

// Request/Response models for Create Flag
Expand All @@ -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"`
Expand All @@ -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"`
}

Expand All @@ -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"`
}