Skip to content

Commit 063521c

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

File tree

4 files changed

+347
-0
lines changed

4 files changed

+347
-0
lines changed

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@types/aws-lambda": "^8.10.158",
7676
"axios": "^1.13.2",
7777
"dotenv": "^17.2.3",
78+
"es-toolkit": "^1.42.0",
7879
"isomorphic-dompurify": "^2.32.0",
7980
"jsonwebtoken": "^9.0.2",
8081
"jwt-decode": "^4.0.0",

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)