Skip to content

Commit eef6c26

Browse files
VIA-537 SB Initial lazy config object.
VIA-537 SB Add SSM retrieval. VIA-537 SB Add test for missing values and reuse of values from cache. VIA-537 SB Implement TTL. VIA-537 SB Docstrings for lazy objects. VIA-537 SB Should retry if SSM call fails. VIA-537 SB Set TTL in reset method, and log resets. VIA-537 SB Improve type coercion.
1 parent e95f821 commit eef6c26

File tree

2 files changed

+335
-0
lines changed

2 files changed

+335
-0
lines changed

src/utils/lazy-config.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import getSSMParam from "@src/utils/get-ssm-param";
2+
import lazyConfig, { ConfigError } from "@src/utils/lazy-config";
3+
import { randomString } from "@test-data/meta-builder";
4+
5+
jest.mock("@src/utils/get-ssm-param");
6+
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
7+
8+
describe("lazyConfig", () => {
9+
const nowInSeconds = 0;
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
jest.useFakeTimers();
14+
jest.setSystemTime(nowInSeconds * 1000);
15+
lazyConfig.resetCache();
16+
process.env = {
17+
NODE_ENV: "test",
18+
};
19+
});
20+
21+
afterEach(() => {
22+
jest.resetAllMocks();
23+
});
24+
25+
afterAll(() => {
26+
jest.useRealTimers();
27+
});
28+
29+
const setupTestEnvVars = (prefix: string) => {
30+
process.env.SSM_PREFIX = prefix;
31+
process.env.CONTENT_API_ENDPOINT = "https://api-endpoint";
32+
process.env.ELIGIBILITY_API_ENDPOINT = "https://elid-endpoint";
33+
process.env.APIM_AUTH_URL = "https://apim-endpoint";
34+
};
35+
36+
it("should return values from env if present, else from getSSMParam", async () => {
37+
const prefix: string = "test/";
38+
setupTestEnvVars(prefix);
39+
const mockGetSSMParam = (getSSMParam as jest.Mock).mockResolvedValue("api-key");
40+
41+
expect(await lazyConfig.CONTENT_API_ENDPOINT).toEqual(new URL("https://api-endpoint"));
42+
expect(await lazyConfig.CONTENT_API_KEY).toEqual("api-key");
43+
44+
expect(mockGetSSMParam).toHaveBeenCalledWith(`${prefix}CONTENT_API_KEY`);
45+
expect(mockGetSSMParam).not.toHaveBeenCalledWith(`${prefix}CONTENT_API_ENDPOINT`);
46+
});
47+
48+
it("should throw error if values aren't in env or SSM", async () => {
49+
const prefix: string = "test/";
50+
process.env.SSM_PREFIX = prefix;
51+
const mockGetSSMParam = (getSSMParam as jest.Mock).mockResolvedValue(undefined);
52+
53+
await expect(async () => {
54+
await lazyConfig.CONTENT_API_ENDPOINT;
55+
await lazyConfig.CONTENT_API_KEY;
56+
}).rejects.toThrow("Unable to get config item CONTENT_API_ENDPOINT");
57+
expect(mockGetSSMParam).not.toHaveBeenCalledWith(`${prefix}CONTENT_API_KEY`);
58+
expect(mockGetSSMParam).toHaveBeenCalledWith(`${prefix}CONTENT_API_ENDPOINT`);
59+
expect(mockGetSSMParam).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("should convert IS_APIM_AUTH_ENABLED to a false boolean value", async () => {
63+
setupTestEnvVars("test/");
64+
process.env.IS_APIM_AUTH_ENABLED = "false";
65+
66+
const actual = await lazyConfig.IS_APIM_AUTH_ENABLED;
67+
68+
expect(actual).toBe(false);
69+
});
70+
71+
it("should convert IS_APIM_AUTH_ENABLED to a true boolean value", async () => {
72+
setupTestEnvVars("test/");
73+
process.env.IS_APIM_AUTH_ENABLED = "true";
74+
75+
const actual = await lazyConfig.IS_APIM_AUTH_ENABLED;
76+
77+
expect(actual).toBe(true);
78+
});
79+
80+
it("should convert MAX_SESSION_AGE_MINUTES to a number", async () => {
81+
setupTestEnvVars("test/");
82+
process.env.MAX_SESSION_AGE_MINUTES = "99";
83+
84+
const actual = await lazyConfig.MAX_SESSION_AGE_MINUTES;
85+
86+
expect(actual).toBe(99);
87+
});
88+
89+
it("should throw for invalid URL", async () => {
90+
setupTestEnvVars("test/");
91+
process.env.APIM_AUTH_URL = "not-a-url";
92+
93+
await expect(async () => {
94+
await lazyConfig.APIM_AUTH_URL;
95+
}).rejects.toThrow(ConfigError);
96+
});
97+
98+
it("should throw for invalid number", async () => {
99+
setupTestEnvVars("test/");
100+
process.env.MAX_SESSION_AGE_MINUTES = "not-a-number";
101+
102+
await expect(async () => {
103+
await lazyConfig.MAX_SESSION_AGE_MINUTES;
104+
}).rejects.toThrow(ConfigError);
105+
});
106+
107+
it("should reuse config values between subsequent calls", async () => {
108+
setupTestEnvVars("test/");
109+
const mockGetSSMParam = (getSSMParam as jest.Mock).mockImplementation(() => randomString(5));
110+
111+
await lazyConfig.NHS_LOGIN_CLIENT_ID;
112+
113+
expect(mockGetSSMParam).toHaveBeenCalled();
114+
mockGetSSMParam.mockClear();
115+
116+
await lazyConfig.NHS_LOGIN_CLIENT_ID;
117+
118+
expect(mockGetSSMParam).not.toHaveBeenCalled();
119+
});
120+
121+
it("should expire config after ttl", async () => {
122+
setupTestEnvVars("test/");
123+
const mockGetSSMParam = (getSSMParam as jest.Mock).mockImplementation(() => "test-value");
124+
125+
await lazyConfig.CONTENT_API_KEY;
126+
127+
expect(mockGetSSMParam).toHaveBeenCalled();
128+
mockGetSSMParam.mockClear();
129+
jest.setSystemTime(nowInSeconds * 1000 + 300 * 1000 + 1);
130+
131+
await lazyConfig.CONTENT_API_KEY;
132+
133+
expect(mockGetSSMParam).toHaveBeenCalled();
134+
});
135+
136+
it("should retry fetching from SSM if the first attempt fails", async () => {
137+
const key = "API_SECRET";
138+
const expectedValue = "value-from-ssm-on-second-try";
139+
const expectedSsmPath = `/test/ci/${key}`;
140+
141+
process.env.SSM_PREFIX = "/test/ci/";
142+
143+
const mockGetSSMParam = (getSSMParam as jest.Mock)
144+
.mockRejectedValueOnce(new Error("SSM is temporarily unavailable"))
145+
.mockResolvedValue(expectedValue);
146+
147+
const resultPromise = lazyConfig.API_SECRET;
148+
await jest.runOnlyPendingTimersAsync();
149+
const result = await resultPromise;
150+
151+
expect(result).toBe(expectedValue);
152+
expect(mockGetSSMParam).toHaveBeenCalledTimes(2);
153+
expect(mockGetSSMParam).toHaveBeenCalledWith(expectedSsmPath);
154+
});
155+
});

src/utils/lazy-config.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { EligibilityApiError } from "@src/services/eligibility-api/gateway/exceptions";
2+
import getSSMParam from "@src/utils/get-ssm-param";
3+
import { logger } from "@src/utils/logger";
4+
import { retry } from "es-toolkit";
5+
import { Logger } from "pino";
6+
7+
const log: Logger = logger.child({ module: "lazy-config" });
8+
9+
type ConfigValue = string | number | boolean | URL | undefined;
10+
11+
/**
12+
* A wrapper around an object which intercepts access to properties. If the property really exists on the object,
13+
* that's what the caller gets, but if it doesn't, the object's getAttribute("property-name") is called instead.
14+
*/
15+
function createReadOnlyDynamic<T extends object>(instance: T): T & { [key: string]: Promise<unknown> } {
16+
const handler: ProxyHandler<T> = {
17+
get(target, prop, receiver) {
18+
if (prop in target) {
19+
return Reflect.get(target, prop, receiver);
20+
}
21+
if (typeof prop === "symbol") {
22+
return Reflect.get(target, prop, receiver);
23+
}
24+
if ("getAttribute" in target && typeof target.getAttribute === "function") {
25+
return target.getAttribute(prop);
26+
}
27+
return Promise.resolve(undefined);
28+
},
29+
};
30+
31+
return new Proxy(instance, handler) as T & { [key: string]: Promise<unknown> };
32+
}
33+
34+
/**
35+
* Config object which only loads config items when, and crucially if, they are used.
36+
* Loads config from environment if it exists there, from SSM otherwise.
37+
* Caches items for CACHE_TTL_MILLIS milliseconds, so we don't get items more than once.
38+
*/
39+
class LazyConfig {
40+
private _cache = new Map<string, ConfigValue>();
41+
private ttl: number = Date.now() + LazyConfig.CACHE_TTL_MILLIS;
42+
static readonly CACHE_TTL_MILLIS: number = 300 * 1000;
43+
44+
private static toUrl = (value: string): URL => new URL(value);
45+
private static toBoolean = (value: string): boolean | undefined => {
46+
const lower = value.toLowerCase();
47+
if (lower === "true") return true;
48+
if (lower === "false") return false;
49+
return undefined;
50+
};
51+
static readonly converters: Record<string, (value: string) => ConfigValue> = {
52+
APIM_AUTH_URL: LazyConfig.toUrl,
53+
CONTENT_API_ENDPOINT: LazyConfig.toUrl,
54+
ELIGIBILITY_API_ENDPOINT: LazyConfig.toUrl,
55+
NBS_URL: LazyConfig.toUrl,
56+
NHS_LOGIN_URL: LazyConfig.toUrl,
57+
CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED: LazyConfig.toBoolean,
58+
IS_APIM_AUTH_ENABLED: LazyConfig.toBoolean,
59+
MAX_SESSION_AGE_MINUTES: (value: string) => {
60+
const num = Number(value);
61+
if (!isNaN(num)) return num;
62+
return undefined;
63+
},
64+
};
65+
66+
/**
67+
* Make sure that the config items are returned as the correct types - booleans, numbers, strings, what have you.
68+
*/
69+
private _coerceType(key: string, value: string | undefined): ConfigValue {
70+
let result: ConfigValue;
71+
72+
if (value === undefined || value.trim() === "") {
73+
result = undefined;
74+
} else {
75+
const converter = LazyConfig.converters[key];
76+
if (!converter) {
77+
result = value.trim();
78+
} else {
79+
try {
80+
result = converter(value.trim());
81+
} catch (error) {
82+
log.warn({ context: { key, value }, error }, "Config item type coercion failed");
83+
result = undefined;
84+
}
85+
}
86+
}
87+
88+
if (result === undefined) {
89+
log.error({ context: { key, value } }, "Unable to get config item");
90+
throw new ConfigError(`Unable to get config item ${key}`);
91+
}
92+
return result;
93+
}
94+
95+
public async getAttribute(key: string): Promise<ConfigValue> {
96+
if (this.ttl < Date.now()) {
97+
(this.resetCache(), (this.ttl = Date.now() + LazyConfig.CACHE_TTL_MILLIS));
98+
}
99+
100+
if (this._cache.has(key)) {
101+
log.debug({ context: { key } }, "cache hit");
102+
return this._cache.get(key);
103+
}
104+
105+
log.debug({ context: { key } }, "cache miss");
106+
const value = await this.getFromEnvironmentOrSSM(key);
107+
108+
const coercedValue = this._coerceType(key, value);
109+
110+
this._cache.set(key, coercedValue);
111+
112+
return coercedValue;
113+
}
114+
115+
private getFromEnvironmentOrSSM = async (key: string): Promise<string> => {
116+
let value = process.env[key];
117+
118+
if (value === undefined || value === null) {
119+
const ssmPrefix = await this.getAttribute("SSM_PREFIX");
120+
121+
if (typeof ssmPrefix !== "string" || ssmPrefix === "") {
122+
log.error(
123+
{ context: { key, ssmPrefix } },
124+
"SSM_PREFIX is not configured correctly. Expected a non-empty string.",
125+
);
126+
throw new Error(`SSM_PREFIX is not configured correctly. Expected a non-empty string, but got: ${ssmPrefix}`);
127+
}
128+
129+
log.debug({ context: { key, ssmPrefix } }, "getting from SSM");
130+
value = await retry(() => getSSMParam(`${ssmPrefix}${key}`), {
131+
retries: 10,
132+
delay: (attempt) => 100 * Math.pow(2, attempt - 1),
133+
});
134+
}
135+
136+
if (value === undefined || value === null) {
137+
log.error({ context: { key } }, "Unable to get config item.");
138+
throw new ConfigError(`Unable to get config item ${key}`);
139+
}
140+
141+
return value;
142+
}
143+
144+
private async getSsmPrefix(): Promise<string> {
145+
const key = "SSM_PREFIX";
146+
147+
if (this._cache.has(key)) {
148+
return this._cache.get(key) as string;
149+
}
150+
151+
const prefix = process.env[key];
152+
if (typeof prefix === "string" && prefix !== "") {
153+
this._cache.set(key, prefix);
154+
return prefix;
155+
}
156+
157+
log.error({ context: { key } }, "SSM_PREFIX is not configured in the environment.");
158+
throw new ConfigError("SSM_PREFIX is not configured correctly in the environment.");
159+
}
160+
161+
public resetCache() {
162+
log.info("reset cache");
163+
this._cache.clear();
164+
this.ttl = Date.now() + LazyConfig.CACHE_TTL_MILLIS;
165+
}
166+
}
167+
168+
export class ConfigError extends EligibilityApiError {
169+
constructor(message: string) {
170+
super(message);
171+
Object.setPrototypeOf(this, new.target.prototype);
172+
this.name = "ConfigError";
173+
}
174+
}
175+
176+
const lazyConfigInstance = new LazyConfig();
177+
178+
const lazyConfig = createReadOnlyDynamic(lazyConfigInstance);
179+
180+
export default lazyConfig;

0 commit comments

Comments
 (0)