Skip to content

Commit c1f06cb

Browse files
Copilotaepfligemini-code-assist[bot]toddbaert
authored
feat: normalize selector in sync (use header as in OFREP and RPC) (#1815)
## Problem Currently, flagd services have inconsistent handling of selectors: - **Sync Service**: Uses `selector` field from the request body - **Evaluation Service** (v1 & v2): Uses `Flagd-Selector` header - **OFREP Service**: Uses `Flagd-Selector` header This inconsistency makes the API surface less predictable and can lead to confusion for users. ## Solution This PR normalizes selector handling by updating the sync service to accept the `Flagd-Selector` header as the primary method, aligning it with the evaluation and OFREP services. The implementation maintains full backward compatibility by falling back to the request body selector when the header is not present. ## Changes ### Core Implementation Modified `flagd/pkg/service/flag-sync/handler.go`: - Added `getSelectorExpression()` helper method that checks for `Flagd-Selector` header (via gRPC metadata) first - Falls back to request body `selector` field if header is not present - Logs a deprecation warning when the request body selector is used - Applied the new logic to both `SyncFlags()` and `FetchAllFlags()` methods ### Test Coverage Added 5 comprehensive test cases in `handler_test.go`: - Header-based selector (verifies no deprecation warning) - Request body selector (verifies backward compatibility and deprecation warning) - Header precedence (verifies header takes priority when both are provided) - FetchAllFlags with header selector - FetchAllFlags with request body selector All existing tests continue to pass. ## Migration Path The change is fully backward compatible. Users can migrate at their convenience: **Current (deprecated):** ```go req := &syncv1.FetchAllFlagsRequest{ Selector: "source:my-source", } resp, err := client.FetchAllFlags(ctx, req) ``` **Recommended:** ```go md := metadata.New(map[string]string{ "Flagd-Selector": "source:my-source", }) ctx := metadata.NewOutgoingContext(context.Background(), md) req := &syncv1.FetchAllFlagsRequest{} resp, err := client.FetchAllFlags(ctx, req) ``` ## Benefits - **Consistency**: All flagd services now use the same `Flagd-Selector` header pattern - **Backward Compatibility**: Existing clients continue to work without modification - **Clear Migration Path**: Deprecation warnings guide users to adopt the new approach - **Aligns with Standards**: Using headers/metadata for query/filter parameters is a common gRPC pattern ## Breaking Changes None. The request body selector will be removed in a future major version (e.g., v2.0.0). ## Related Addresses #[issue_number] <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[FEATURE] Normalize selector handling across sync, evaluation, and OFREP services</issue_title> > <issue_description>### Requirements > > ### Problem > Currently, we have inconsistent handling of selectors across different flagd services: > > - **Sync Service** (`flagd/pkg/service/flag-sync/handler.go:34`): Uses `req.GetSelector()` from the request body > - **Evaluation Service**: Uses `flagd-selector` header > - **OFREP Service**: Uses `flagd-selector` header > > This inconsistency can lead to confusion for users and makes the API surface less predictable. > > ### Proposal > Normalize selector handling across all services by: > > 1. **Preferred approach**: Use the `flagd-selector` header as the primary method for all services > 2. **Backward compatibility**: For the sync service, continue to accept `selector` in the request body as a fallback for a deprecation period > > ### Implementation Strategy > 1. Update sync service to check for `flagd-selector` header first > 2. Fall back to request body `selector` field if header is not present > 3. Log deprecation warning when using request body selector > 4. Document the change and migration path > 5. Remove request body selector support in a future major version (e.g., v2.0.0) > > ### Benefits > - Consistent API across all flagd services > - Easier to understand and use for developers > - Aligns with common patterns for query/filter parameters in gRPC metadata > > ### Breaking Changes > None initially (backward compatible), but request body selector would be removed in a future major release. > > ### Additional Context > The sync service currently processes the selector at: > ```go > selectorExpression := req.GetSelector() > selector := store.NewSelector(selectorExpression) > ``` > > This should be updated to prioritize metadata/header values while maintaining backward compatibility.</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> Fixes #1814 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/open-feature/flagd/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. Relates to: open-feature/flagd-schemas#199 --------- Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> Co-authored-by: Simon Schrottner <simon.schrottner@dynatrace.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
1 parent efda06a commit c1f06cb

File tree

2 files changed

+164
-2
lines changed

2 files changed

+164
-2
lines changed

flagd/pkg/service/flag-sync/handler.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import (
1111
"github.com/open-feature/flagd/core/pkg/model"
1212

1313
"google.golang.org/grpc/codes"
14+
"google.golang.org/grpc/metadata"
1415
"google.golang.org/grpc/status"
1516

1617
"buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
1718
syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
1819
"github.com/open-feature/flagd/core/pkg/logger"
1920
"github.com/open-feature/flagd/core/pkg/store"
21+
flagdService "github.com/open-feature/flagd/flagd/pkg/service"
2022
"google.golang.org/protobuf/types/known/structpb"
2123
)
2224

@@ -32,7 +34,7 @@ type syncHandler struct {
3234

3335
func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.FlagSyncService_SyncFlagsServer) error {
3436
watcher := make(chan store.FlagQueryResult, 1)
35-
selectorExpression := req.GetSelector()
37+
selectorExpression := s.getSelectorExpression(server.Context(), req)
3638
selector := store.NewSelector(selectorExpression)
3739
ctx := server.Context()
3840

@@ -85,6 +87,39 @@ func (s syncHandler) SyncFlags(req *syncv1.SyncFlagsRequest, server syncv1grpc.F
8587
}
8688
}
8789

90+
// getSelectorExpression extracts the selector expression from the request.
91+
// It first checks the Flagd-Selector header (metadata), then falls back to the request body selector.
92+
//
93+
// The req parameter accepts *syncv1.SyncFlagsRequest or *syncv1.FetchAllFlagsRequest.
94+
// Using interface{} here is intentional as both protobuf-generated types implement GetSelector()
95+
// but do not share a common interface.
96+
func (s syncHandler) getSelectorExpression(ctx context.Context, req interface{}) string {
97+
// Try to get selector from metadata (header)
98+
if md, ok := metadata.FromIncomingContext(ctx); ok {
99+
if values := md.Get(flagdService.FLAGD_SELECTOR_HEADER); len(values) > 0 {
100+
headerSelector := values[0]
101+
s.log.Debug(fmt.Sprintf("using selector from request header: %s", headerSelector))
102+
return headerSelector
103+
}
104+
}
105+
106+
// Fall back to request body selector for backward compatibility
107+
// Eventually we will want to log a deprecation warning here and then remote it entirely
108+
var bodySelector string
109+
type selectorGetter interface {
110+
GetSelector() string
111+
}
112+
113+
if r, ok := req.(selectorGetter); ok {
114+
bodySelector = r.GetSelector()
115+
}
116+
117+
if bodySelector != "" {
118+
s.log.Debug(fmt.Sprintf("using selector from request body: %s", bodySelector))
119+
}
120+
return bodySelector
121+
}
122+
88123
func (s syncHandler) convertMap(flags []model.Flag) map[string]model.Flag {
89124
flagMap := make(map[string]model.Flag, len(flags))
90125
for _, flag := range flags {
@@ -96,7 +131,7 @@ func (s syncHandler) convertMap(flags []model.Flag) map[string]model.Flag {
96131
func (s syncHandler) FetchAllFlags(ctx context.Context, req *syncv1.FetchAllFlagsRequest) (
97132
*syncv1.FetchAllFlagsResponse, error,
98133
) {
99-
selectorExpression := req.GetSelector()
134+
selectorExpression := s.getSelectorExpression(ctx, req)
100135
selector := store.NewSelector(selectorExpression)
101136
flags, _, err := s.store.GetAll(ctx, &selector)
102137
if err != nil {

flagd/pkg/service/flag-sync/handler_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ package sync
22

33
import (
44
"context"
5+
"strings"
56
"sync"
67
"testing"
78
"time"
89

910
"buf.build/gen/go/open-feature/flagd/grpc/go/flagd/sync/v1/syncv1grpc"
1011
syncv1 "buf.build/gen/go/open-feature/flagd/protocolbuffers/go/flagd/sync/v1"
1112
"github.com/open-feature/flagd/core/pkg/logger"
13+
"github.com/open-feature/flagd/core/pkg/model"
1214
"github.com/open-feature/flagd/core/pkg/store"
15+
flagdService "github.com/open-feature/flagd/flagd/pkg/service"
1316
"github.com/stretchr/testify/assert"
1417
"github.com/stretchr/testify/require"
18+
"google.golang.org/grpc/metadata"
1519
)
1620

1721
func TestSyncHandler_SyncFlags(t *testing.T) {
@@ -128,3 +132,126 @@ func (m *mockSyncFlagsServer) GetLastResponse() *syncv1.SyncFlagsResponse {
128132
defer m.mu.Unlock()
129133
return m.lastResp
130134
}
135+
136+
// Test that selector from header takes precedence over selector from request body for FetchAllFlags and SyncFlags methods.
137+
func TestSyncHandler_SelectorLocationPrecedence(t *testing.T) {
138+
headerFlags := []model.Flag{
139+
{
140+
Key: "header-flag",
141+
State: "ENABLED",
142+
DefaultVariant: "true",
143+
Variants: testVariants,
144+
},
145+
}
146+
147+
bodyFlags := []model.Flag{
148+
{
149+
Key: "body-flag",
150+
State: "DISABLED",
151+
DefaultVariant: "false",
152+
Variants: testVariants,
153+
},
154+
}
155+
156+
tests := []struct {
157+
name string
158+
hasHeader bool
159+
headerSelector string
160+
bodySelector string
161+
expectedFlag string
162+
expectedSource string
163+
shouldNotContain string
164+
}{
165+
{
166+
name: "SyncFlags with request body selector only",
167+
hasHeader: false,
168+
bodySelector: "source=body-source",
169+
expectedFlag: "body-flag",
170+
expectedSource: "body-source",
171+
shouldNotContain: "header-flag",
172+
},
173+
{
174+
name: "SyncFlags header takes precedence over request body",
175+
hasHeader: true,
176+
headerSelector: "source=header-source",
177+
bodySelector: "source=body-source",
178+
expectedFlag: "header-flag",
179+
expectedSource: "header-source",
180+
shouldNotContain: "body-flag",
181+
},
182+
{
183+
name: "FetchAllFlags with request body selector only",
184+
hasHeader: false,
185+
bodySelector: "source=body-source",
186+
expectedFlag: "body-flag",
187+
expectedSource: "body-source",
188+
shouldNotContain: "header-flag",
189+
},
190+
{
191+
name: "FetchAllFlags header takes precedence over request body",
192+
hasHeader: true,
193+
headerSelector: "source=header-source",
194+
bodySelector: "source=body-source",
195+
expectedFlag: "header-flag",
196+
expectedSource: "header-source",
197+
shouldNotContain: "body-flag",
198+
},
199+
}
200+
201+
for _, tt := range tests {
202+
t.Run(tt.name, func(t *testing.T) {
203+
flagStore, err := store.NewStore(logger.NewLogger(nil, false), []string{})
204+
flagStore.Update("header-source", headerFlags, nil)
205+
flagStore.Update("body-source", bodyFlags, nil)
206+
require.NoError(t, err)
207+
208+
handler := syncHandler{
209+
store: flagStore,
210+
log: logger.NewLogger(nil, false),
211+
contextValues: map[string]any{},
212+
}
213+
214+
// Create context with or without header metadata
215+
var ctx context.Context
216+
if tt.hasHeader {
217+
md := metadata.New(map[string]string{
218+
flagdService.FLAGD_SELECTOR_HEADER: tt.headerSelector,
219+
})
220+
ctx = metadata.NewIncomingContext(context.Background(), md)
221+
} else {
222+
ctx = context.Background()
223+
}
224+
225+
if strings.Contains(tt.name, "SyncFlags") {
226+
// Test SyncFlags
227+
stream := &mockSyncFlagsServer{
228+
ctx: ctx,
229+
mu: sync.Mutex{},
230+
respReady: make(chan struct{}, 1),
231+
}
232+
233+
go func() {
234+
err := handler.SyncFlags(&syncv1.SyncFlagsRequest{Selector: tt.bodySelector}, stream)
235+
assert.NoError(t, err)
236+
}()
237+
238+
select {
239+
case <-stream.respReady:
240+
assert.Contains(t, stream.lastResp.FlagConfiguration, tt.expectedFlag)
241+
assert.Contains(t, stream.lastResp.FlagConfiguration, tt.expectedSource)
242+
assert.NotContains(t, stream.lastResp.FlagConfiguration, tt.shouldNotContain)
243+
case <-time.After(time.Second):
244+
t.Fatal("timeout waiting for response")
245+
}
246+
} else {
247+
// Test FetchAllFlags
248+
resp, err := handler.FetchAllFlags(ctx, &syncv1.FetchAllFlagsRequest{Selector: tt.bodySelector})
249+
require.NoError(t, err)
250+
251+
assert.Contains(t, resp.FlagConfiguration, tt.expectedFlag)
252+
assert.Contains(t, resp.FlagConfiguration, tt.expectedSource)
253+
assert.NotContains(t, resp.FlagConfiguration, tt.shouldNotContain)
254+
}
255+
})
256+
}
257+
}

0 commit comments

Comments
 (0)