Skip to content

Commit 17fd726

Browse files
authored
feat: Use SyncContext from SyncFlagsResponse instead of Metadata Request (#1401)
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent 8feffee commit 17fd726

File tree

13 files changed

+164
-59
lines changed

13 files changed

+164
-59
lines changed

libs/providers/flagd/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"current-version": "echo $npm_package_version"
88
},
99
"peerDependencies": {
10-
"@grpc/grpc-js": "~1.8.0 || ~1.9.0 || ~1.10.0 || ~1.11.0 || ~1.12.0 || ~1.13.0",
10+
"@grpc/grpc-js": "~1.8.0 || ~1.9.0 || ~1.10.0 || ~1.11.0 || ~1.12.0 || ~1.13.0 || ~1.14.0",
1111
"@openfeature/server-sdk": "^1.17.0"
1212
},
1313
"dependencies": {
Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,6 @@
11
import { getGherkinTestPath } from '@openfeature/flagd-core';
22

3-
export const FLAGD_NAME = 'flagd';
43
export const UNSTABLE_CLIENT_NAME = 'unstable';
54
export const UNAVAILABLE_CLIENT_NAME = 'unavailable';
65

76
export const GHERKIN_FLAGD = getGherkinTestPath('*.feature');
8-
export const CONNECTION_FEATURE = getGherkinTestPath('connection.feature');
9-
export const CONTEXT_ENRICHMENT_FEATURE = getGherkinTestPath('contextEnrichment.feature');
10-
export const EVALUATION_FEATURE = getGherkinTestPath('evaluation.feature');
11-
export const EVENTS_FEATURE = getGherkinTestPath('events.feature');
12-
export const METADATA_FEATURE = getGherkinTestPath('metadata.feature');
13-
export const RPC_CACHING_FEATURE = getGherkinTestPath('rpc-caching.feature');
14-
export const SELECTOR_FEATURE = getGherkinTestPath('selector.feature');
15-
export const TARGETING_FEATURE = getGherkinTestPath('targeting.feature');
16-
export const GHERKIN_EVALUATION_FEATURE = getGherkinTestPath(
17-
'evaluation.feature',
18-
'spec/specification/assets/gherkin/',
19-
);

libs/providers/flagd/src/e2e/step-definitions/providerSteps.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import { ProviderStatus } from '@openfeature/server-sdk';
12
import { OpenFeature } from '@openfeature/server-sdk';
23
import { FlagdContainer } from '../tests/flagdContainer';
34
import type { State, Steps } from './state';
45
import { FlagdProvider } from '../../lib/flagd-provider';
56
import type { FlagdProviderOptions } from '../../lib/configuration';
6-
import { ProviderStatus } from '@openfeature/server-sdk';
77

88
export const providerSteps: Steps =
99
(state: State) =>
10-
({ given, when, then }) => {
10+
({ given, when, then, and }) => {
1111
const container: FlagdContainer = FlagdContainer.build();
1212
beforeAll(async () => {
1313
console.log('Setting flagd provider...');
@@ -34,26 +34,29 @@ export const providerSteps: Steps =
3434
const flagdOptions: FlagdProviderOptions = {
3535
resolverType: state.resolverType,
3636
deadlineMs: 2000,
37+
...state.config,
38+
...state.options,
3739
};
40+
3841
let type = 'default';
3942
switch (providerType) {
40-
default:
41-
flagdOptions['port'] = container.getPort(state.resolverType);
42-
if (state?.options?.['selector']) {
43-
flagdOptions['selector'] = state?.options?.['selector'] as string;
44-
}
45-
break;
4643
case 'unavailable':
4744
flagdOptions['port'] = 9999;
4845
break;
4946
case 'ssl':
5047
// TODO: modify this to support ssl
5148
flagdOptions['port'] = container.getPort(state.resolverType);
52-
if (state?.config?.selector) {
53-
flagdOptions['selector'] = state.config.selector;
54-
}
5549
type = 'ssl';
5650
break;
51+
case 'stable':
52+
flagdOptions['port'] = container.getPort(state.resolverType);
53+
break;
54+
case 'syncpayload':
55+
flagdOptions['port'] = container.getPort(state.resolverType);
56+
type = 'sync-payload';
57+
break;
58+
default:
59+
throw new Error('unknown provider type: ' + providerType);
5760
}
5861

5962
await fetch('http://' + container.getLaunchpadUrl() + '/start?config=' + type);

libs/providers/flagd/src/e2e/tests/in-process.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('in-process', () => {
2222
// remove filters as we add support for features
2323
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
2424
tagFilter:
25-
'@in-process and not @targetURI and not @forbidden and not @customCert and not @events and not @sync and not @grace and not @metadata and not @unixsocket and not @sync-payload and not @contextEnrichment',
25+
'@in-process and not @targetURI and not @forbidden and not @customCert and not @events and not @sync and not @grace and not @metadata and not @unixsocket',
2626
scenarioNameTemplate: (vars) => {
2727
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
2828
},

libs/providers/flagd/src/e2e/tests/rpc.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('rpc', () => {
2323
tagFilter:
2424
// remove filters as we add support for features
2525
// see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues
26-
'@rpc and not @targetURI and not @customCert and not @forbidden and not @events and not @stream and not @grace and not @metadata and not @contextEnrichment and not @caching',
26+
'@rpc and not @targetURI and not @customCert and not @forbidden and not @events and not @stream and not @grace and not @metadata and not @caching and not @unixsocket',
2727
scenarioNameTemplate: (vars) => {
2828
return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`;
2929
},
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Hook, EvaluationContext, BeforeHookContext, HookHints } from '@openfeature/server-sdk';
2+
3+
export class SyncMetadataHook implements Hook {
4+
enrichedContext: () => EvaluationContext;
5+
6+
constructor(enrichedContext: () => EvaluationContext) {
7+
this.enrichedContext = enrichedContext;
8+
}
9+
10+
public before(hookContext: BeforeHookContext, hookHints?: HookHints): EvaluationContext {
11+
return this.enrichedContext();
12+
}
13+
}

libs/providers/flagd/src/lib/configuration.spec.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Config, FlagdProviderOptions } from './configuration';
22
import { getConfig } from './configuration';
33
import { DEFAULT_MAX_CACHE_SIZE } from './constants';
4+
import type { EvaluationContext } from '@openfeature/server-sdk';
45

56
describe('Configuration', () => {
67
const OLD_ENV = process.env;
@@ -20,6 +21,7 @@ describe('Configuration', () => {
2021
resolverType: 'rpc',
2122
selector: '',
2223
deadlineMs: 500,
24+
contextEnricher: expect.any(Function),
2325
});
2426
});
2527

@@ -46,19 +48,33 @@ describe('Configuration', () => {
4648
process.env['FLAGD_OFFLINE_FLAG_SOURCE_PATH'] = offlineFlagSourcePath;
4749
process.env['FLAGD_DEFAULT_AUTHORITY'] = defaultAuthority;
4850

49-
expect(getConfig()).toStrictEqual({
50-
host,
51-
port,
52-
tls,
53-
socketPath,
54-
maxCacheSize,
55-
cache,
56-
resolverType,
57-
selector,
58-
offlineFlagSourcePath,
59-
defaultAuthority,
60-
deadlineMs: 500,
61-
});
51+
expect(getConfig()).toEqual(
52+
expect.objectContaining({
53+
host,
54+
port,
55+
tls,
56+
socketPath,
57+
maxCacheSize,
58+
cache,
59+
resolverType,
60+
selector,
61+
offlineFlagSourcePath,
62+
defaultAuthority,
63+
deadlineMs: 500,
64+
}),
65+
);
66+
});
67+
68+
it('should override context enricher', () => {
69+
const contextEnricher = (syncContext: EvaluationContext | null): EvaluationContext => {
70+
return { ...syncContext, extraKey: 'extraValue' };
71+
};
72+
73+
expect(getConfig({ contextEnricher }).contextEnricher({})).toEqual({ extraKey: 'extraValue' });
74+
});
75+
76+
it('should return identity function', () => {
77+
expect(getConfig().contextEnricher({})).toStrictEqual({});
6278
});
6379

6480
it('should use flagd sync port over flagd port environment option', () => {
@@ -76,6 +92,9 @@ describe('Configuration', () => {
7692
});
7793

7894
it('should use incoming options over defaults and environment variable', () => {
95+
const contextEnricher = (syncContext: EvaluationContext | null): EvaluationContext => {
96+
return { ...syncContext, extraKey: 'extraValue' };
97+
};
7998
const options: FlagdProviderOptions = {
8099
host: 'test',
81100
port: 3000,
@@ -86,6 +105,7 @@ describe('Configuration', () => {
86105
selector: '',
87106
defaultAuthority: '',
88107
deadlineMs: 500,
108+
contextEnricher: contextEnricher,
89109
};
90110

91111
process.env['FLAGD_HOST'] = 'override';

libs/providers/flagd/src/lib/configuration.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DEFAULT_MAX_CACHE_SIZE } from './constants';
2+
import type { EvaluationContext } from '@openfeature/server-sdk';
23

34
export type CacheOption = 'lru' | 'disabled';
45
export type ResolverType = 'rpc' | 'in-process';
@@ -83,20 +84,34 @@ export interface Config {
8384
defaultAuthority?: string;
8485
}
8586

86-
export type FlagdProviderOptions = Partial<Config>;
87+
interface FlagdConfig extends Config {
88+
/**
89+
* Function providing an EvaluationContext to mix into every evaluation.
90+
* The syncContext from the SyncFlagsResponse
91+
* (https://buf.build/open-feature/flagd/docs/main:flagd.sync.v1#flagd.sync.v1.SyncFlagsResponse),
92+
* represented as a {@link dev.openfeature.sdk.Structure}, is passed as an argument.
93+
*
94+
* This function runs every time the provider (re)connects, and its result is cached and used in every evaluation.
95+
* By default, the entire sync response (as a JSON Object) is used.
96+
*/
97+
contextEnricher: (syncContext: EvaluationContext | null) => EvaluationContext;
98+
}
99+
100+
export type FlagdProviderOptions = Partial<FlagdConfig>;
87101

88-
const DEFAULT_CONFIG: Omit<Config, 'port' | 'resolverType'> = {
102+
const DEFAULT_CONFIG: Omit<FlagdConfig, 'port' | 'resolverType'> = {
89103
deadlineMs: 500,
90104
host: 'localhost',
91105
tls: false,
92106
selector: '',
93107
cache: 'lru',
94108
maxCacheSize: DEFAULT_MAX_CACHE_SIZE,
109+
contextEnricher: (syncContext: EvaluationContext | null) => syncContext ?? {},
95110
};
96111

97-
const DEFAULT_RPC_CONFIG: Config = { ...DEFAULT_CONFIG, resolverType: 'rpc', port: 8013 };
112+
const DEFAULT_RPC_CONFIG: FlagdConfig = { ...DEFAULT_CONFIG, resolverType: 'rpc', port: 8013 };
98113

99-
const DEFAULT_IN_PROCESS_CONFIG: Config = { ...DEFAULT_CONFIG, resolverType: 'in-process', port: 8015 };
114+
const DEFAULT_IN_PROCESS_CONFIG: FlagdConfig = { ...DEFAULT_CONFIG, resolverType: 'in-process', port: 8015 };
100115

101116
enum ENV_VAR {
102117
FLAGD_HOST = 'FLAGD_HOST',
@@ -171,7 +186,7 @@ const getEnvVarConfig = (): Partial<Config> => {
171186
};
172187
};
173188

174-
export function getConfig(options: FlagdProviderOptions = {}) {
189+
export function getConfig(options: FlagdProviderOptions = {}): FlagdConfig {
175190
const envVarConfig = getEnvVarConfig();
176191
const defaultConfig =
177192
options.resolverType == 'in-process' || envVarConfig.resolverType == 'in-process'

libs/providers/flagd/src/lib/flagd-provider.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import { getConfig } from './configuration';
55
import { GRPCService } from './service/grpc/grpc-service';
66
import type { Service } from './service/service';
77
import { InProcessService } from './service/in-process/in-process-service';
8+
import type { Hook } from '@openfeature/server-sdk';
9+
import { SyncMetadataHook } from './SyncMetadataHook';
810

911
export class FlagdProvider implements Provider {
1012
metadata = {
1113
name: 'flagd',
1214
};
1315

16+
readonly hooks?: Hook[];
1417
readonly runsOn = 'server';
1518
readonly events = new OpenFeatureEventEmitter();
19+
private syncContext: EvaluationContext | null = null;
1620

1721
private readonly _service: Service;
1822

@@ -30,11 +34,27 @@ export class FlagdProvider implements Provider {
3034
) {
3135
const config = getConfig(options);
3236

33-
this._service = service
34-
? service
35-
: config.resolverType === 'in-process'
36-
? new InProcessService(config, undefined, logger)
37-
: new GRPCService(config, undefined, logger);
37+
if (service === undefined) {
38+
if (config.resolverType === 'in-process') {
39+
this._service = new InProcessService(config, this.setSyncContext.bind(this), undefined, logger);
40+
41+
if (config?.offlineFlagSourcePath === undefined) {
42+
this.hooks = [new SyncMetadataHook(() => config.contextEnricher(this.getSyncContext()))];
43+
}
44+
} else {
45+
this._service = new GRPCService(config, undefined, logger);
46+
}
47+
} else {
48+
this._service = service;
49+
}
50+
}
51+
52+
setSyncContext(context: EvaluationContext) {
53+
this.syncContext = context;
54+
}
55+
56+
getSyncContext(): EvaluationContext | null {
57+
return this.syncContext;
3858
}
3959

4060
async initialize(): Promise<void> {

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const disconnectCallback = jest.fn();
1818
const removeAllListeners = jest.fn();
1919
const cancel = jest.fn();
2020
const destroy = jest.fn();
21+
const setSyncContext = jest.fn();
2122

2223
let onDataCallback: (data: SyncFlagsResponse) => void = () => ({});
2324
let onErrorCallback: (err: Error) => void = () => ({});
@@ -59,11 +60,12 @@ describe('grpc fetch', () => {
5960

6061
it('should handle data sync and emit callbacks', (done) => {
6162
const flagConfiguration = '{"flags":{}}';
62-
const fetch = new GrpcFetch(cfg, serviceMock);
63+
const fetch = new GrpcFetch(cfg, setSyncContext, serviceMock);
6364
fetch
6465
.connect(dataCallback, reconnectCallback, jest.fn(), disconnectCallback)
6566
.then(() => {
6667
try {
68+
expect(setSyncContext).toHaveBeenCalledTimes(0);
6769
expect(dataCallback).toHaveBeenCalledTimes(1);
6870
expect(dataCallback).toHaveBeenCalledWith(flagConfiguration);
6971
expect(changedCallback).toHaveBeenCalledTimes(0);
@@ -80,14 +82,40 @@ describe('grpc fetch', () => {
8082
onDataCallback({ flagConfiguration });
8183
});
8284

85+
it('should handle SyncContext from SyncFlagsResponse', (done) => {
86+
const initFlagConfig = '{"flags":{}}';
87+
const syncContext = { test: 'example' };
88+
89+
const fetch = new GrpcFetch(cfg, setSyncContext, serviceMock);
90+
fetch
91+
.connect(dataCallback, reconnectCallback, changedCallback, disconnectCallback)
92+
.then(() => {
93+
try {
94+
// Callback assertions
95+
expect(setSyncContext).toHaveBeenCalledTimes(1);
96+
expect(setSyncContext).toHaveBeenCalledWith(syncContext);
97+
98+
done();
99+
} catch (err) {
100+
done(err);
101+
}
102+
})
103+
.catch((err) => {
104+
done(err);
105+
});
106+
107+
// First connection
108+
onDataCallback({ flagConfiguration: initFlagConfig, syncContext: syncContext });
109+
});
110+
83111
it('should handle data sync reconnection', (done) => {
84112
const initFlagConfig = '{"flags":{}}';
85113
const updatedFlagConfig =
86114
'{"flags":{"test":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"}}}';
87115
const reconnectFlagConfig =
88116
'{"flags":{"test":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"}}}';
89117

90-
const fetch = new GrpcFetch(cfg, serviceMock);
118+
const fetch = new GrpcFetch(cfg, jest.fn(), serviceMock);
91119
fetch
92120
.connect(dataCallback, reconnectCallback, changedCallback, disconnectCallback)
93121
.then(() => {
@@ -128,7 +156,7 @@ describe('grpc fetch', () => {
128156
});
129157

130158
it('should handle error and watch channel for reconnect', (done) => {
131-
const fetch = new GrpcFetch(cfg, serviceMock);
159+
const fetch = new GrpcFetch(cfg, jest.fn(), serviceMock);
132160
fetch.connect(jest.fn(), jest.fn(), jest.fn(), disconnectCallback).catch((err) => {
133161
try {
134162
expect(err).toBeInstanceOf(Error);

0 commit comments

Comments
 (0)