Skip to content

Commit 4b5613c

Browse files
Copilotaepfli
andcommitted
Add flagd-selector gRPC metadata header support for in-process mode
- Import Metadata from @grpc/grpc-js - Create and populate _metadata with flagd-selector header when selector is configured - Pass metadata to syncFlags gRPC call - Maintain backward compatibility by keeping selector in request field - Add comprehensive tests for selector metadata handling - Document selector implementation and deprecation in README Co-authored-by: aepfli <[email protected]>
1 parent 955e50d commit 4b5613c

File tree

3 files changed

+105
-2
lines changed

3 files changed

+105
-2
lines changed

libs/providers/flagd/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ To enable this mode, you should provide a valid flag configuration file with the
9494
Offline mode uses `fs.watchFile` and polls every 5 seconds for changes to the file.
9595
This mode is useful for local development, test cases, and for offline applications.
9696

97+
### Selector (in-process mode only)
98+
99+
The `selector` option allows filtering flag configurations from the sync service in in-process mode. This is useful when multiple flag configurations are available and you want to target a specific subset.
100+
101+
```ts
102+
OpenFeature.setProvider(new FlagdProvider({
103+
resolverType: 'in-process',
104+
selector: 'app=weather',
105+
}))
106+
```
107+
108+
> [!NOTE]
109+
> **Selector Implementation Details**: As of this release, the selector is sent to flagd via both:
110+
> 1. The `flagd-selector` gRPC metadata header (new standard, see [flagd#1814](https://github.com/open-feature/flagd/issues/1814))
111+
> 2. The `selector` field in the `SyncFlagsRequest` message (deprecated, for backward compatibility)
112+
>
113+
> The request field will be removed in a future major version once the deprecation period ends.
114+
> This dual approach ensures compatibility with both newer and older flagd versions during the transition period.
115+
97116
### Default Authority usage (optional)
98117

99118
This is useful for complex routing or service-discovery use cases that involve a proxy (e.g., Envoy).

libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { GrpcFetch } from './grpc-fetch';
22
import type { Config } from '../../../configuration';
33
import type { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
44
import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state';
5+
import type { Metadata } from '@grpc/grpc-js';
56

67
let watchStateCallback: () => void = () => ({});
78
const mockChannel = {
@@ -143,4 +144,77 @@ describe('grpc fetch', () => {
143144

144145
onErrorCallback(new Error('Some connection error'));
145146
});
147+
148+
it('should send selector via flagd-selector metadata header', (done) => {
149+
const selector = 'app=weather';
150+
const cfgWithSelector: Config = { ...cfg, selector };
151+
const flagConfiguration = '{"flags":{}}';
152+
153+
const fetch = new GrpcFetch(cfgWithSelector, serviceMock);
154+
fetch
155+
.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback)
156+
.then(() => {
157+
try {
158+
// Verify syncFlags was called
159+
expect(serviceMock.syncFlags).toHaveBeenCalled();
160+
161+
// Check that both request and metadata were passed
162+
const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0];
163+
expect(callArgs).toHaveLength(2);
164+
165+
// Verify the request contains selector (for backward compatibility)
166+
expect(callArgs[0].selector).toBe(selector);
167+
168+
// Verify the metadata contains flagd-selector header
169+
const metadata = callArgs[1] as Metadata;
170+
expect(metadata).toBeDefined();
171+
expect(metadata.get('flagd-selector')).toEqual([selector]);
172+
173+
done();
174+
} catch (err) {
175+
done(err);
176+
}
177+
})
178+
.catch((err) => {
179+
done(err);
180+
});
181+
182+
onDataCallback({ flagConfiguration });
183+
});
184+
185+
it('should handle empty selector gracefully', (done) => {
186+
const cfgWithoutSelector: Config = { ...cfg, selector: '' };
187+
const flagConfiguration = '{"flags":{}}';
188+
189+
const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock);
190+
fetch
191+
.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback)
192+
.then(() => {
193+
try {
194+
// Verify syncFlags was called
195+
expect(serviceMock.syncFlags).toHaveBeenCalled();
196+
197+
// Check that both request and metadata were passed
198+
const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0];
199+
expect(callArgs).toHaveLength(2);
200+
201+
// Verify the request contains empty selector
202+
expect(callArgs[0].selector).toBe('');
203+
204+
// Verify the metadata does not contain flagd-selector header
205+
const metadata = callArgs[1] as Metadata;
206+
expect(metadata).toBeDefined();
207+
expect(metadata.get('flagd-selector')).toEqual([]);
208+
209+
done();
210+
} catch (err) {
211+
done(err);
212+
}
213+
})
214+
.catch((err) => {
215+
done(err);
216+
});
217+
218+
onDataCallback({ flagConfiguration });
219+
});
146220
});

libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ClientReadableStream, ServiceError, ClientOptions } from '@grpc/grpc-js';
2-
import { credentials } from '@grpc/grpc-js';
2+
import { credentials, Metadata } from '@grpc/grpc-js';
33
import type { Logger } from '@openfeature/server-sdk';
44
import { GeneralError } from '@openfeature/server-sdk';
55
import type { SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync';
@@ -14,6 +14,7 @@ import type { DataFetch } from '../data-fetch';
1414
export class GrpcFetch implements DataFetch {
1515
private readonly _syncClient: FlagSyncServiceClient;
1616
private readonly _request: SyncFlagsRequest;
17+
private readonly _metadata: Metadata;
1718
private _syncStream: ClientReadableStream<SyncFlagsResponse> | undefined;
1819
private _logger: Logger | undefined;
1920
/**
@@ -47,7 +48,16 @@ export class GrpcFetch implements DataFetch {
4748
);
4849

4950
this._logger = logger;
51+
// For backward compatibility during the deprecation period, we send the selector in both:
52+
// 1. The request field (deprecated, for older flagd versions)
53+
// 2. The gRPC metadata header 'flagd-selector' (new standard)
5054
this._request = { providerId: '', selector: selector ? selector : '' };
55+
56+
// Create metadata with the flagd-selector header
57+
this._metadata = new Metadata();
58+
if (selector) {
59+
this._metadata.set('flagd-selector', selector);
60+
}
5161
}
5262

5363
async connect(
@@ -79,7 +89,7 @@ export class GrpcFetch implements DataFetch {
7989
this._logger?.debug('Starting gRPC sync connection');
8090
closeStreamIfDefined(this._syncStream);
8191
try {
82-
this._syncStream = this._syncClient.syncFlags(this._request);
92+
this._syncStream = this._syncClient.syncFlags(this._request, this._metadata);
8393
this._syncStream.on('data', (data: SyncFlagsResponse) => {
8494
this._logger?.debug(`Received sync payload`);
8595

0 commit comments

Comments
 (0)