diff --git a/libs/providers/flagd/README.md b/libs/providers/flagd/README.md index f7f1ecc9e..22725ca78 100644 --- a/libs/providers/flagd/README.md +++ b/libs/providers/flagd/README.md @@ -94,6 +94,25 @@ To enable this mode, you should provide a valid flag configuration file with the Offline mode uses `fs.watchFile` and polls every 5 seconds for changes to the file. This mode is useful for local development, test cases, and for offline applications. +### Selector (in-process mode only) + +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. + +```ts + OpenFeature.setProvider(new FlagdProvider({ + resolverType: 'in-process', + selector: 'app=weather', + })) +``` + +> [!NOTE] +> **Selector Implementation Details**: As of this release, the selector is sent to flagd via both: +> 1. The `flagd-selector` gRPC metadata header (new standard, see [flagd#1814](https://github.com/open-feature/flagd/issues/1814)) +> 2. The `selector` field in the `SyncFlagsRequest` message (deprecated, for backward compatibility) +> +> The request field will be removed in a future major version once the deprecation period ends. +> This dual approach ensures compatibility with both newer and older flagd versions during the transition period. + ### Default Authority usage (optional) This is useful for complex routing or service-discovery use cases that involve a proxy (e.g., Envoy). diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts index ed9035998..d32326aba 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.spec.ts @@ -2,6 +2,7 @@ import { GrpcFetch } from './grpc-fetch'; import type { Config } from '../../../configuration'; import type { FlagSyncServiceClient, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync'; import { ConnectivityState } from '@grpc/grpc-js/build/src/connectivity-state'; +import type { Metadata } from '@grpc/grpc-js'; let watchStateCallback: () => void = () => ({}); const mockChannel = { @@ -143,4 +144,55 @@ describe('grpc fetch', () => { onErrorCallback(new Error('Some connection error')); }); + + it('should send selector via flagd-selector metadata header', async () => { + const selector = 'app=weather'; + const cfgWithSelector: Config = { ...cfg, selector }; + const flagConfiguration = '{"flags":{}}'; + + const fetch = new GrpcFetch(cfgWithSelector, serviceMock); + const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback); + onDataCallback({ flagConfiguration }); + await connectPromise; + + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); + + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); + + // Verify the request contains selector (for backward compatibility) + expect(callArgs[0].selector).toBe(selector); + + // Verify the metadata contains flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([selector]); + }); + + it('should handle empty selector gracefully', async () => { + const cfgWithoutSelector: Config = { ...cfg, selector: '' }; + const flagConfiguration = '{"flags":{}}'; + + const fetch = new GrpcFetch(cfgWithoutSelector, serviceMock); + const connectPromise = fetch.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback); + onDataCallback({ flagConfiguration }); + await connectPromise; + + // Verify syncFlags was called + expect(serviceMock.syncFlags).toHaveBeenCalled(); + + // Check that both request and metadata were passed + const callArgs = (serviceMock.syncFlags as jest.Mock).mock.calls[0]; + expect(callArgs).toHaveLength(2); + + // Verify the request contains empty selector + expect(callArgs[0].selector).toBe(''); + + // Verify the metadata does not contain flagd-selector header + const metadata = callArgs[1] as Metadata; + expect(metadata).toBeDefined(); + expect(metadata.get('flagd-selector')).toEqual([]); + }); }); diff --git a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts index 45c374b3b..ced620a8d 100644 --- a/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts +++ b/libs/providers/flagd/src/lib/service/in-process/grpc/grpc-fetch.ts @@ -1,5 +1,5 @@ import type { ClientReadableStream, ServiceError, ClientOptions } from '@grpc/grpc-js'; -import { credentials } from '@grpc/grpc-js'; +import { credentials, Metadata } from '@grpc/grpc-js'; import type { Logger } from '@openfeature/server-sdk'; import { GeneralError } from '@openfeature/server-sdk'; import type { SyncFlagsRequest, SyncFlagsResponse } from '../../../../proto/ts/flagd/sync/v1/sync'; @@ -14,6 +14,7 @@ import type { DataFetch } from '../data-fetch'; export class GrpcFetch implements DataFetch { private readonly _syncClient: FlagSyncServiceClient; private readonly _request: SyncFlagsRequest; + private readonly _metadata: Metadata; private _syncStream: ClientReadableStream | undefined; private _logger: Logger | undefined; /** @@ -47,7 +48,16 @@ export class GrpcFetch implements DataFetch { ); this._logger = logger; + // For backward compatibility during the deprecation period, we send the selector in both: + // 1. The request field (deprecated, for older flagd versions) + // 2. The gRPC metadata header 'flagd-selector' (new standard) this._request = { providerId: '', selector: selector ? selector : '' }; + + // Create metadata with the flagd-selector header + this._metadata = new Metadata(); + if (selector) { + this._metadata.set('flagd-selector', selector); + } } async connect( @@ -79,7 +89,7 @@ export class GrpcFetch implements DataFetch { this._logger?.debug('Starting gRPC sync connection'); closeStreamIfDefined(this._syncStream); try { - this._syncStream = this._syncClient.syncFlags(this._request); + this._syncStream = this._syncClient.syncFlags(this._request, this._metadata); this._syncStream.on('data', (data: SyncFlagsResponse) => { this._logger?.debug(`Received sync payload`);