Skip to content

Commit 19c82bd

Browse files
authored
feat: Add support for environment variables for OFREP provider config… (#1450)
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
1 parent ca3c16a commit 19c82bd

File tree

10 files changed

+471
-25
lines changed

10 files changed

+471
-25
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ function checkEnvVarResolverType() {
197197
);
198198
}
199199

200-
const getEnvVarConfig = (): Partial<Config & FlagdGrpcConfig> => {
200+
function getEnvVarConfig(): Partial<Config & FlagdGrpcConfig> {
201201
let provider = undefined;
202202
if (
203203
process.env[ENV_VAR.FLAGD_RESOLVER] &&
@@ -269,7 +269,7 @@ const getEnvVarConfig = (): Partial<Config & FlagdGrpcConfig> => {
269269
retryGracePeriod: Number(process.env[ENV_VAR.FLAGD_RETRY_GRACE_PERIOD]),
270270
}),
271271
};
272-
};
272+
}
273273

274274
export function getConfig(options: FlagdProviderOptions = {}): FlagdConfig & FlagdGrpcConfig {
275275
const envVarConfig = getEnvVarConfig();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { GoFeatureFlagProviderOptions } from './go-feature-flag-provider-options';
2+
import { EvaluationType } from './model';
3+
4+
describe('GoFeatureFlagProviderOptions', () => {
5+
describe('Type checking for endpoint configuration', () => {
6+
it('should allow endpoint for InProcess evaluation (default)', () => {
7+
const options: GoFeatureFlagProviderOptions = {
8+
endpoint: 'https://api.example.com',
9+
evaluationType: EvaluationType.InProcess,
10+
};
11+
12+
expect(options.endpoint).toBe('https://api.example.com');
13+
});
14+
15+
it('should allow endpoint when using Remote evaluation', () => {
16+
const options: GoFeatureFlagProviderOptions = {
17+
endpoint: 'https://api.example.com',
18+
evaluationType: EvaluationType.Remote,
19+
};
20+
21+
expect(options.endpoint).toBe('https://api.example.com');
22+
});
23+
24+
it('should allow omitting endpoint for evaluation type remote', () => {
25+
const options: GoFeatureFlagProviderOptions = {
26+
evaluationType: EvaluationType.Remote,
27+
};
28+
29+
expect(options.endpoint).toBeUndefined();
30+
});
31+
32+
it('should allow all base options with endpoint', () => {
33+
const options: GoFeatureFlagProviderOptions = {
34+
endpoint: 'https://api.example.com',
35+
timeout: 5000,
36+
flagChangePollingIntervalMs: 30000,
37+
dataFlushInterval: 60000,
38+
maxPendingEvents: 5000,
39+
disableDataCollection: true,
40+
apiKey: 'test-key',
41+
};
42+
43+
expect(options.endpoint).toBe('https://api.example.com');
44+
expect(options.timeout).toBe(5000);
45+
expect(options.flagChangePollingIntervalMs).toBe(30000);
46+
expect(options.dataFlushInterval).toBe(60000);
47+
expect(options.maxPendingEvents).toBe(5000);
48+
expect(options.disableDataCollection).toBe(true);
49+
expect(options.apiKey).toBe('test-key');
50+
});
51+
52+
it('should allow all base options without endpoint when evaluation type is remote', () => {
53+
const options: GoFeatureFlagProviderOptions = {
54+
timeout: 5000,
55+
flagChangePollingIntervalMs: 30000,
56+
disableDataCollection: false,
57+
evaluationType: EvaluationType.Remote,
58+
};
59+
60+
expect(options.endpoint).toBeUndefined();
61+
expect(options.timeout).toBe(5000);
62+
expect(options.flagChangePollingIntervalMs).toBe(30000);
63+
expect(options.disableDataCollection).toBe(false);
64+
});
65+
});
66+
});

libs/providers/go-feature-flag/src/lib/go-feature-flag-provider-options.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
11
import type { EvaluationType, ExporterMetadata } from './model';
22
import type { FetchAPI } from './helper/fetch-api';
33

4-
export interface GoFeatureFlagProviderOptions {
5-
/**
6-
* The endpoint of the GO Feature Flag relay-proxy.
7-
*/
8-
endpoint: string;
9-
10-
/**
11-
* The type of evaluation to use.
12-
* @default EvaluationType.InProcess
13-
*/
14-
evaluationType?: EvaluationType;
15-
4+
export interface GoFeatureFlagProviderBaseOptions {
165
/**
176
* The timeout for HTTP requests in milliseconds.
187
* @default 10000
@@ -67,3 +56,28 @@ export interface GoFeatureFlagProviderOptions {
6756
*/
6857
wasmBinaryPath?: string;
6958
}
59+
60+
/**
61+
* The evaluation type remote does not require an endpoint, because it can be
62+
* set by the environment variable OFREP_ENDPOINT.
63+
*/
64+
export type GoFeatureFlagProviderOptions = GoFeatureFlagProviderBaseOptions &
65+
(
66+
| {
67+
/**
68+
* The endpoint of the GO Feature Flag relay-proxy.
69+
*/
70+
endpoint: string;
71+
72+
/**
73+
* The type of evaluation to use.
74+
* @default EvaluationType.InProcess
75+
*/
76+
evaluationType?: Omit<EvaluationType, 'Remote'>;
77+
}
78+
| {
79+
endpoint?: string;
80+
81+
evaluationType: EvaluationType.Remote;
82+
}
83+
);

libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ describe('GoFeatureFlagProvider', () => {
6565
expect(() => new GoFeatureFlagProvider(undefined as any)).toThrow('No options provided');
6666
});
6767

68+
it('should not throw InvalidOptionsException when EvaluationType is Remote', () => {
69+
expect(
70+
() =>
71+
new GoFeatureFlagProvider({
72+
evaluationType: EvaluationType.Remote,
73+
}),
74+
).toThrow('The given OFREP URL "" is not a valid URL.');
75+
});
76+
77+
it('should not throw exception when EvaluationType is Remote and env variable is set', () => {
78+
process.env['OFREP_ENDPOINT'] = 'https://api.example.com';
79+
80+
expect(
81+
() =>
82+
new GoFeatureFlagProvider({
83+
evaluationType: EvaluationType.Remote,
84+
}),
85+
).not.toThrow();
86+
87+
delete process.env['OFREP_ENDPOINT'];
88+
});
89+
6890
it('should throw InvalidOptionsException when endpoint is null', () => {
6991
expect(
7092
() =>

libs/providers/go-feature-flag/src/lib/go-feature-flag-provider.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,19 @@ export class GoFeatureFlagProvider implements Provider, Tracking {
157157
throw new InvalidOptionsException('No options provided');
158158
}
159159

160-
if (!options.endpoint || options.endpoint.trim() === '') {
160+
if ((!options.endpoint || options.endpoint.trim() === '') && options.evaluationType !== EvaluationType.Remote) {
161161
throw new InvalidOptionsException('endpoint is a mandatory field when initializing the provider');
162162
}
163163

164-
try {
165-
const url = new URL(options.endpoint);
166-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
164+
if (options.evaluationType !== EvaluationType.Remote && options.endpoint !== undefined) {
165+
try {
166+
const url = new URL(options.endpoint);
167+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
168+
throw new InvalidOptionsException('endpoint must be a valid URL (http or https)');
169+
}
170+
} catch {
167171
throw new InvalidOptionsException('endpoint must be a valid URL (http or https)');
168172
}
169-
} catch {
170-
throw new InvalidOptionsException('endpoint must be a valid URL (http or https)');
171173
}
172174

173175
if (options.flagChangePollingIntervalMs !== undefined && options.flagChangePollingIntervalMs <= 0) {

libs/providers/go-feature-flag/src/lib/service/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class GoFeatureFlagApi {
4141
throw new InvalidOptionsException('Options cannot be null');
4242
}
4343

44-
this.endpoint = options.endpoint;
44+
this.endpoint = options.endpoint!;
4545
this.timeout = options.timeout || 10000;
4646
this.apiKey = options.apiKey;
4747
this.fetchImplementation = options.fetchImplementation || isomorphicFetch();

libs/providers/ofrep/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@ import { OFREPProvider } from '@openfeature/ofrep-provider';
2929
OpenFeature.setProvider(new OFREPProvider({ baseUrl: 'https://localhost:8080' }));
3030
```
3131

32+
### Environment Variables
33+
34+
The provider supports environment variables for default configuration. These are used when options are not explicitly provided:
35+
36+
| Environment Variable | Description | Example |
37+
| -------------------- | --------------------------------------------------- | -------------------------------------------- |
38+
| `OFREP_ENDPOINT` | The endpoint of the GO Feature Flag relay-proxy | `http://localhost:2321` |
39+
| `OFREP_TIMEOUT_MS` | HTTP request timeout in milliseconds | `5000` |
40+
| `OFREP_HEADERS` | Additional headers as comma-separated key=value pairs | `Authentication=Bearer 123,Content-Type=json` |
41+
42+
#### Example using environment variables:
43+
44+
```bash
45+
export OFREP_ENDPOINT=http://localhost:2321
46+
export OFREP_TIMEOUT_MS=5000
47+
export OFREP_HEADERS=Authentication=Bearer 123,Content-Type=json
48+
```
49+
50+
**Note**: Explicitly provided options always take precedence over environment variables.
51+
3252
### HTTP headers
3353

3454
The provider can use headers from either a static header map or a custom header factory.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type { OFREPProviderBaseOptions } from '@openfeature/ofrep-core';
2+
import { getConfig } from './configuration';
3+
4+
describe('Configuration', () => {
5+
const OLD_ENV = process.env;
6+
7+
beforeEach(() => {
8+
jest.resetModules();
9+
process.env = { ...OLD_ENV };
10+
});
11+
12+
afterEach(() => {
13+
process.env = OLD_ENV;
14+
});
15+
16+
describe('getConfig', () => {
17+
it('should return empty config when no options or env vars are provided', () => {
18+
const config = getConfig();
19+
expect(config).toEqual({
20+
baseUrl: '',
21+
headers: undefined,
22+
});
23+
});
24+
25+
it('should use environment variables as defaults', () => {
26+
process.env['OFREP_ENDPOINT'] = 'https://api.example.com';
27+
process.env['OFREP_TIMEOUT_MS'] = '5000';
28+
process.env['OFREP_HEADERS'] = 'X-Custom-Header=value1,X-Another=value2';
29+
30+
const config = getConfig();
31+
32+
expect(config.baseUrl).toBe('https://api.example.com');
33+
expect(config.timeoutMs).toBe(5000);
34+
expect(config.headers).toStrictEqual([
35+
['x-custom-header', 'value1'],
36+
['x-another', 'value2'],
37+
]);
38+
});
39+
40+
it('should override environment variables with provided options', () => {
41+
process.env['OFREP_ENDPOINT'] = 'https://api.example.com';
42+
process.env['OFREP_TIMEOUT_MS'] = '5000';
43+
44+
const options: OFREPProviderBaseOptions = {
45+
baseUrl: 'https://override.example.com',
46+
timeoutMs: 10000,
47+
};
48+
49+
const config = getConfig(options);
50+
51+
expect(config.baseUrl).toBe('https://override.example.com');
52+
expect(config.timeoutMs).toBe(10000);
53+
});
54+
55+
it('should handle invalid timeoutMs value in environment variable', () => {
56+
process.env['OFREP_TIMEOUT_MS'] = 'invalid';
57+
58+
const config = getConfig();
59+
60+
expect(config.timeoutMs).toBeUndefined();
61+
});
62+
63+
it('should parse fully URL-encoded headers', () => {
64+
// Encoded: "Authorization=Bearer token123,X-Custom=value with spaces"
65+
process.env['OFREP_HEADERS'] = 'Authorization%3DBearer%20token123%2CX-Custom%3Dvalue%20with%20spaces';
66+
67+
const config = getConfig();
68+
69+
expect(config.headers).toStrictEqual([
70+
['authorization', 'Bearer token123'],
71+
['x-custom', 'value with spaces'],
72+
]);
73+
});
74+
75+
it('should parse partially URL-encoded headers (commas not encoded)', () => {
76+
// Only values are encoded, commas are not
77+
process.env['OFREP_HEADERS'] = 'Authorization=Bearer%20token123,X-Custom=value%20with%20spaces';
78+
79+
const config = getConfig();
80+
81+
expect(config.headers).toStrictEqual([
82+
['authorization', 'Bearer token123'],
83+
['x-custom', 'value with spaces'],
84+
]);
85+
});
86+
87+
it('should handle header values containing equals signs', () => {
88+
process.env['OFREP_HEADERS'] = 'X-Signature=key=value=another,Authorization=Bearer token';
89+
90+
const config = getConfig();
91+
92+
expect(config.headers).toStrictEqual([
93+
['x-signature', 'key=value=another'],
94+
['authorization', 'Bearer token'],
95+
]);
96+
});
97+
98+
it('should handle URL-encoded header values with equals signs', () => {
99+
// Encoded: "X-Data=param1=value1&param2=value2"
100+
process.env['OFREP_HEADERS'] = 'X-Data=param1%3Dvalue1%26param2%3Dvalue2';
101+
102+
const config = getConfig();
103+
104+
expect(config.headers).toStrictEqual([['x-data', 'param1=value1&param2=value2']]);
105+
});
106+
107+
it('should skip malformed headers without equals sign and log warning', () => {
108+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
109+
process.env['OFREP_HEADERS'] = 'ValidHeader=value,InvalidHeader,AnotherValid=value2';
110+
111+
const config = getConfig();
112+
113+
expect(config.headers).toStrictEqual([
114+
['validheader', 'value'],
115+
['anothervalid', 'value2'],
116+
]);
117+
expect(consoleSpy).toHaveBeenCalledWith('Skipping malformed header entry (missing equals sign): "InvalidHeader"');
118+
consoleSpy.mockRestore();
119+
});
120+
121+
it('should skip headers with empty keys and log warning', () => {
122+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
123+
process.env['OFREP_HEADERS'] = 'ValidHeader=value,=valueWithoutKey,AnotherValid=value2';
124+
125+
const config = getConfig();
126+
127+
expect(config.headers).toStrictEqual([
128+
['validheader', 'value'],
129+
['anothervalid', 'value2'],
130+
]);
131+
expect(consoleSpy).toHaveBeenCalledWith('Skipping malformed header entry (missing key): "=valueWithoutKey"');
132+
consoleSpy.mockRestore();
133+
});
134+
135+
it('should skip headers with whitespace-only keys and log warning', () => {
136+
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
137+
process.env['OFREP_HEADERS'] = 'ValidHeader=value, =valueWithSpaceKey,AnotherValid=value2';
138+
139+
const config = getConfig();
140+
141+
expect(config.headers).toStrictEqual([
142+
['validheader', 'value'],
143+
['anothervalid', 'value2'],
144+
]);
145+
expect(consoleSpy).toHaveBeenCalledWith('Skipping malformed header entry (missing key): " =valueWithSpaceKey"');
146+
consoleSpy.mockRestore();
147+
});
148+
149+
it('should allow headers with empty values', () => {
150+
process.env['OFREP_HEADERS'] = 'X-Empty=,X-Valid=value';
151+
152+
const config = getConfig();
153+
154+
expect(config.headers).toStrictEqual([
155+
['x-empty', ''],
156+
['x-valid', 'value'],
157+
]);
158+
});
159+
160+
it('should trim whitespace from keys and values', () => {
161+
process.env['OFREP_HEADERS'] = ' X-Header1 = value1 , X-Header2 = value2 ';
162+
163+
const config = getConfig();
164+
165+
expect(config.headers).toStrictEqual([
166+
['x-header1', 'value1'],
167+
['x-header2', 'value2'],
168+
]);
169+
});
170+
});
171+
});

0 commit comments

Comments
 (0)