Skip to content

Commit 6221c67

Browse files
committed
refactor: improve public interface + don't expose resolver
1 parent a62c037 commit 6221c67

2 files changed

Lines changed: 273 additions & 8 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { Reforge } from "../reforge";
2+
import { ConfigType, ConfigValueType } from "../types";
3+
import type { Config } from "../types";
4+
import { projectEnvIdUnderTest, irrelevant } from "./testHelpers";
5+
6+
const createSimpleConfig = (key: string, value: string): Config => {
7+
return {
8+
id: "1",
9+
projectId: 1,
10+
key,
11+
changedBy: undefined,
12+
rows: [
13+
{
14+
properties: {},
15+
values: [
16+
{
17+
criteria: [
18+
{
19+
propertyName: "user.country",
20+
operator: "PROP_IS_ONE_OF" as any,
21+
valueToMatch: {
22+
stringList: {
23+
values: ["US"],
24+
},
25+
},
26+
},
27+
],
28+
value: { string: value },
29+
},
30+
{
31+
criteria: [],
32+
value: { string: "default" },
33+
},
34+
],
35+
},
36+
],
37+
allowableValues: [],
38+
configType: ConfigType.Config,
39+
valueType: ConfigValueType.String,
40+
sendToClientSdk: false,
41+
};
42+
};
43+
44+
describe("ReforgeClient", () => {
45+
describe("withContext", () => {
46+
it("returns a context-scoped client that doesn't expose internal resolver", () => {
47+
const reforge = new Reforge({ sdkKey: irrelevant });
48+
const config = createSimpleConfig("test.key", "us-value");
49+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
50+
51+
const scopedClient = reforge.withContext({ user: { country: "US" } });
52+
53+
// Should have the public API methods
54+
expect(typeof scopedClient.get).toBe("function");
55+
expect(typeof scopedClient.isFeatureEnabled).toBe("function");
56+
expect(typeof scopedClient.logger).toBe("function");
57+
expect(typeof scopedClient.shouldLog).toBe("function");
58+
expect(typeof scopedClient.getLogLevel).toBe("function");
59+
expect(typeof scopedClient.withContext).toBe("function");
60+
expect(typeof scopedClient.inContext).toBe("function");
61+
expect(typeof scopedClient.updateIfStalerThan).toBe("function");
62+
expect(typeof scopedClient.addConfigChangeListener).toBe("function");
63+
64+
// Should NOT expose internal resolver methods
65+
expect((scopedClient as any).raw).toBeUndefined();
66+
expect((scopedClient as any).set).toBeUndefined();
67+
expect((scopedClient as any).keys).toBeUndefined();
68+
expect((scopedClient as any).cloneWithContext).toBeUndefined();
69+
expect((scopedClient as any).update).toBeUndefined();
70+
71+
// Should apply context correctly
72+
expect(scopedClient.get("test.key")).toBe("us-value");
73+
});
74+
75+
it("allows chaining withContext calls", () => {
76+
const reforge = new Reforge({ sdkKey: irrelevant });
77+
const config = createSimpleConfig("test.key", "us-value");
78+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
79+
80+
const client1 = reforge.withContext({ user: { country: "FR" } });
81+
expect(client1.get("test.key")).toBe("default");
82+
83+
const client2 = client1.withContext({ user: { country: "US" } });
84+
expect(client2.get("test.key")).toBe("us-value");
85+
});
86+
87+
it("merges context when additional context is provided to methods", () => {
88+
const reforge = new Reforge({ sdkKey: irrelevant });
89+
const config = createSimpleConfig("test.key", "us-value");
90+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
91+
92+
const client = reforge.withContext({ user: { name: "Alice" } });
93+
94+
// Provide additional context that includes the country
95+
expect(client.get("test.key", { user: { country: "US" } })).toBe(
96+
"us-value"
97+
);
98+
});
99+
});
100+
101+
describe("inContext", () => {
102+
it("provides a context-scoped client to the callback", () => {
103+
const reforge = new Reforge({ sdkKey: irrelevant });
104+
const config = createSimpleConfig("test.key", "us-value");
105+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
106+
107+
const result = reforge.inContext(
108+
{ user: { country: "US" } },
109+
(client) => {
110+
// Should not expose internal methods
111+
expect((client as any).raw).toBeUndefined();
112+
expect((client as any).set).toBeUndefined();
113+
114+
// Should apply context
115+
expect(client.get("test.key")).toBe("us-value");
116+
117+
return "success";
118+
}
119+
);
120+
121+
expect(result).toBe("success");
122+
});
123+
124+
it("allows nested inContext calls", () => {
125+
const reforge = new Reforge({ sdkKey: irrelevant });
126+
const config = createSimpleConfig("test.key", "us-value");
127+
reforge.setConfig([config], projectEnvIdUnderTest, new Map());
128+
129+
reforge.inContext({ user: { country: "FR" } }, (outer) => {
130+
expect(outer.get("test.key")).toBe("default");
131+
132+
outer.inContext({ user: { country: "US" } }, (inner) => {
133+
expect(inner.get("test.key")).toBe("us-value");
134+
});
135+
});
136+
});
137+
});
138+
139+
describe("shared state", () => {
140+
it("shares telemetry with parent", () => {
141+
const reforge = new Reforge({ sdkKey: irrelevant });
142+
reforge.setConfig([], projectEnvIdUnderTest, new Map());
143+
144+
const client = reforge.withContext({ user: { country: "US" } });
145+
146+
expect(client.telemetry).toBe(reforge.telemetry);
147+
});
148+
149+
it("delegates methods to parent reforge instance", () => {
150+
const reforge = new Reforge({ sdkKey: irrelevant });
151+
reforge.setConfig([], projectEnvIdUnderTest, new Map());
152+
153+
const client = reforge.withContext({ user: { country: "US" } });
154+
155+
// Telemetry is shared
156+
expect(client.telemetry).toBe(reforge.telemetry);
157+
158+
// getLogLevel delegates to parent
159+
expect(typeof client.getLogLevel).toBe("function");
160+
});
161+
});
162+
});

src/reforge.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { apiClient, type ApiClient } from "./apiClient";
22
import { loadConfig } from "./loadConfig";
3-
import { Resolver, type MinimumConfig, type ResolverAPI } from "./resolver";
3+
import { Resolver, type MinimumConfig } from "./resolver";
44
import { Sources } from "./sources";
55
import { jsonStringifyWithBigInt } from "./bigIntUtils";
6+
import { mergeContexts, contextObjToMap } from "./mergeContexts";
67
import {
78
ConfigType,
89
ConfigValueType,
@@ -83,7 +84,8 @@ export interface ReforgeInterface {
8384
) => boolean;
8485
logger: (
8586
loggerName: string,
86-
defaultLevel: LogLevel
87+
defaultLevel?: LogLevel,
88+
contexts?: Contexts | ContextObj
8789
) => ReturnType<typeof makeLogger>;
8890
shouldLog: ({
8991
loggerName,
@@ -99,7 +101,11 @@ export interface ReforgeInterface {
99101
getLogLevel: (loggerName: string) => LogLevel;
100102
telemetry?: Telemetry;
101103
updateIfStalerThan: (durationInMs: number) => Promise<void> | undefined;
102-
withContext: (contexts: Contexts | ContextObj) => ResolverAPI;
104+
withContext: (contexts: Contexts | ContextObj) => ReforgeInterface;
105+
inContext: <T>(
106+
contexts: Contexts | ContextObj,
107+
func: (reforge: ReforgeInterface) => T
108+
) => T;
103109
addConfigChangeListener: (callback: GlobalListenerCallback) => () => void;
104110
}
105111

@@ -129,6 +135,104 @@ interface ConstructorProps {
129135
onUpdate?: (configs: Array<Config | MinimumConfig>) => void;
130136
}
131137

138+
/**
139+
* A context-scoped Reforge client that shares the underlying resolver
140+
* with its parent Reforge instance but applies a specific context to all operations.
141+
*/
142+
class ReforgeClient implements ReforgeInterface {
143+
private readonly parent: Reforge;
144+
private readonly context: Contexts;
145+
146+
constructor(parent: Reforge, contexts: Contexts | ContextObj) {
147+
this.parent = parent;
148+
this.context =
149+
contexts instanceof Map ? contexts : contextObjToMap(contexts);
150+
}
151+
152+
get telemetry(): Telemetry | undefined {
153+
return this.parent.telemetry;
154+
}
155+
156+
get<K extends keyof TypedNodeServerConfigurationRaw>(
157+
key: K,
158+
contexts?: Contexts | ContextObj,
159+
defaultValue?: TypedNodeServerConfigurationRaw[K]
160+
): TypedNodeServerConfigurationRaw[K] {
161+
const mergedContexts = contexts
162+
? mergeContexts(this.context, contexts)
163+
: this.context;
164+
return this.parent.get(key, mergedContexts, defaultValue);
165+
}
166+
167+
isFeatureEnabled(
168+
key: string,
169+
contexts?: Contexts | ContextObj
170+
): boolean {
171+
const mergedContexts = contexts
172+
? mergeContexts(this.context, contexts)
173+
: this.context;
174+
return this.parent.isFeatureEnabled(key, mergedContexts);
175+
}
176+
177+
logger(
178+
loggerName: string,
179+
defaultLevel?: LogLevel,
180+
contexts?: Contexts | ContextObj
181+
): ReturnType<typeof makeLogger> {
182+
const mergedContexts = contexts
183+
? mergeContexts(this.context, contexts)
184+
: this.context;
185+
return this.parent.logger(loggerName, defaultLevel, mergedContexts);
186+
}
187+
188+
shouldLog({
189+
loggerName,
190+
desiredLevel,
191+
defaultLevel,
192+
contexts,
193+
}: {
194+
loggerName: string;
195+
desiredLevel: LogLevel;
196+
defaultLevel?: LogLevel;
197+
contexts?: Contexts | ContextObj;
198+
}): boolean {
199+
const mergedContexts = contexts
200+
? mergeContexts(this.context, contexts)
201+
: this.context;
202+
return this.parent.shouldLog({
203+
loggerName,
204+
desiredLevel,
205+
defaultLevel,
206+
contexts: mergedContexts,
207+
});
208+
}
209+
210+
getLogLevel(loggerName: string): LogLevel {
211+
return this.parent.getLogLevel(loggerName);
212+
}
213+
214+
updateIfStalerThan(durationInMs: number): Promise<void> | undefined {
215+
return this.parent.updateIfStalerThan(durationInMs);
216+
}
217+
218+
withContext(contexts: Contexts | ContextObj): ReforgeInterface {
219+
const mergedContexts = mergeContexts(this.context, contexts);
220+
return new ReforgeClient(this.parent, mergedContexts);
221+
}
222+
223+
inContext<T>(
224+
contexts: Contexts | ContextObj,
225+
func: (reforge: ReforgeInterface) => T
226+
): T {
227+
const mergedContexts = mergeContexts(this.context, contexts);
228+
return func(new ReforgeClient(this.parent, mergedContexts));
229+
}
230+
231+
addConfigChangeListener(callback: GlobalListenerCallback): () => void {
232+
return this.parent.addConfigChangeListener(callback);
233+
}
234+
}
235+
132236
class Reforge implements ReforgeInterface {
133237
private readonly sdkKey: string;
134238
readonly sources: Sources;
@@ -411,17 +515,17 @@ class Reforge implements ReforgeInterface {
411515

412516
inContext<T>(
413517
contexts: Contexts | ContextObj,
414-
func: (reforge: Resolver) => T
518+
func: (reforge: ReforgeInterface) => T
415519
): T {
416520
requireResolver(this.resolver);
417521

418-
return func(this.resolver.cloneWithContext(contexts));
522+
return func(new ReforgeClient(this, contexts));
419523
}
420524

421-
withContext(contexts: Contexts | ContextObj): ResolverAPI {
525+
withContext(contexts: Contexts | ContextObj): ReforgeInterface {
422526
requireResolver(this.resolver);
423527

424-
return this.resolver.cloneWithContext(contexts);
528+
return new ReforgeClient(this, contexts);
425529
}
426530

427531
get<K extends keyof TypedNodeServerConfigurationRaw>(
@@ -562,5 +666,4 @@ export {
562666
type Contexts,
563667
SchemaType,
564668
type Provided,
565-
Resolver,
566669
};

0 commit comments

Comments
 (0)