diff --git a/package-lock.json b/package-lock.json index d0d636b94..758c8d964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24821,6 +24821,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@tsd/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-oRzaozYXHCU4oJaxFUd7tNUNZmqqpPn6ryFSzW5Ub/HuPhMBk4MopIyQomZ0VTJ0Ype+J3UXbBZ96SWsKf6x5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "license": "MIT" @@ -37120,6 +37130,21 @@ "dev": true, "license": "ISC" }, + "node_modules/jest-tsd": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/jest-tsd/-/jest-tsd-0.2.2.tgz", + "integrity": "sha512-Nzgo5E9F1RON3GDkAHxVOf1dqSSRMnhJ5txULHhhK+qs2xSLmLWBbtYjKSlAYNpW/fOXSrGPOs6uE0YyWAchhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "chalk": "^4.1.2", + "tsd-lite": "^0.7.0" + }, + "peerDependencies": { + "@tsd/typescript": "4.x || 5.x" + } + }, "node_modules/jest-util": { "version": "29.7.0", "dev": true, @@ -54324,6 +54349,20 @@ "node": ">=4" } }, + "node_modules/tsd-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tsd-lite/-/tsd-lite-0.7.0.tgz", + "integrity": "sha512-XhQ7w/RPzfjSb98LIQB1qx7yAvRV6+h5JFP4dCvd79Hbp23X8CCx4EdRAQm6faTkRdprKYvZE8kxlK6LpJp8vg==", + "deprecated": "'tsd-lite' is deprecated. For details, see the deprecation notice: https://github.com/mrazauskas/tsd-lite/issues/364", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@tsd/typescript": "4.x || 5.x" + } + }, "node_modules/tslib": { "version": "2.8.1", "license": "0BSD" @@ -60744,6 +60783,7 @@ "@babel/preset-typescript": "^7", "@babel/types": "^7", "@release-it/keep-a-changelog": "^4.0.0", + "@tsd/typescript": "^5.8.2", "@types/common-tags": "^1", "@types/deep-equal": "^1.0.4", "@types/express": "^4.17.17", @@ -60759,6 +60799,7 @@ "eslint-plugin-tsdoc": "^0.2.14", "express": "^4.19.2", "jest": "^29", + "jest-tsd": "^0.2.2", "mongodb-memory-server": "^10.1.2", "node-mocks-http": "^1.16.1", "prettier": "^2", diff --git a/packages/mongodb-rag-core/package.json b/packages/mongodb-rag-core/package.json index 68311e157..7ce10c672 100644 --- a/packages/mongodb-rag-core/package.json +++ b/packages/mongodb-rag-core/package.json @@ -47,6 +47,7 @@ "@babel/preset-typescript": "^7", "@babel/types": "^7", "@release-it/keep-a-changelog": "^4.0.0", + "@tsd/typescript": "^5.8.2", "@types/common-tags": "^1", "@types/deep-equal": "^1.0.4", "@types/express": "^4.17.17", @@ -62,6 +63,7 @@ "eslint-plugin-tsdoc": "^0.2.14", "express": "^4.19.2", "jest": "^29", + "jest-tsd": "^0.2.2", "mongodb-memory-server": "^10.1.2", "node-mocks-http": "^1.16.1", "prettier": "^2", diff --git a/packages/mongodb-rag-core/src/getEnv.test-d.ts b/packages/mongodb-rag-core/src/getEnv.test-d.ts new file mode 100644 index 000000000..c9f577e06 --- /dev/null +++ b/packages/mongodb-rag-core/src/getEnv.test-d.ts @@ -0,0 +1,57 @@ +import { expectType, expectError } from "jest-tsd"; +import { getEnv } from "./getEnv"; + +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +test("required env vars are strings", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ required: ["TEST_ENV_VAR"] }); + expectType(env.TEST_ENV_VAR); +}); + +test("optional env vars with string defaults are strings", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ + optional: { TEST_ENV_VAR: "default", TEST_ENV_VAR_2: "default" }, + }); + expectType(env.TEST_ENV_VAR); + expectType(env.TEST_ENV_VAR_2); +}); + +test("optional env vars with undefined defaults are strings or undefined", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ + optional: { TEST_ENV_VAR: "default", UNDEFINED_TEST_ENV_VAR: undefined }, + }); + expectType(env.TEST_ENV_VAR); + expectType(env.UNDEFINED_TEST_ENV_VAR); +}); + +test("accessing an unspecified env var is a type error", () => { + expectError(getEnv({}).FOO); +}); + +test("mixed env vars are the correct type", () => { + process.env.TEST_ENV_VAR = "test"; + process.env.TEST_ENV_VAR_2 = "test"; + const env = getEnv({ + required: ["TEST_ENV_VAR"], + optional: { + TEST_ENV_VAR_2: "default", + TEST_ENV_VAR_3: "default", + TEST_ENV_VAR_4: undefined, + }, + }); + expectType(env.TEST_ENV_VAR); + expectType(env.TEST_ENV_VAR_2); + expectType(env.TEST_ENV_VAR_3); + expectType(env.TEST_ENV_VAR_4); +}); diff --git a/packages/mongodb-rag-core/src/getEnv.test.ts b/packages/mongodb-rag-core/src/getEnv.test.ts new file mode 100644 index 000000000..22e16b0cd --- /dev/null +++ b/packages/mongodb-rag-core/src/getEnv.test.ts @@ -0,0 +1,94 @@ +import { expectTypeTestsToPassAsync } from "jest-tsd"; +import { getEnv } from "./getEnv"; + +describe("getEnv", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it("does not produce static type errors", async () => { + await expectTypeTestsToPassAsync(__filename); + }); + + it("returns required env vars that are defined", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ required: ["TEST_ENV_VAR"] }); + expect(env).toEqual({ TEST_ENV_VAR: "test" }); + }); + + it("throws an error if a required env var is not defined", () => { + process.env.TEST_ENV_VAR = "test"; + expect(() => + getEnv({ + required: ["TEST_ENV_VAR", "MISSING_ENV_VAR", "MISSING_ENV_VAR_2"], + }) + ).toThrow( + "Missing required environment variables:\n - MISSING_ENV_VAR\n - MISSING_ENV_VAR_2" + ); + }); + + it("returns optional env vars that are defined", () => { + process.env.TEST_ENV_VAR = "test"; + process.env.TEST_ENV_VAR_2 = "test"; + const env = getEnv({ + optional: { TEST_ENV_VAR: "default", TEST_ENV_VAR_2: "default" }, + }); + expect(env).toEqual({ TEST_ENV_VAR: "test", TEST_ENV_VAR_2: "test" }); + }); + + it("returns default values for optional env vars that don't exist", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ + optional: { TEST_ENV_VAR: "default", UNDEFINED_TEST_ENV_VAR: "default" }, + }); + expect(env).toEqual({ + TEST_ENV_VAR: "test", + UNDEFINED_TEST_ENV_VAR: "default", + }); + }); + + it("can default to a string or undefined", () => { + process.env.TEST_ENV_VAR = "test"; + const env = getEnv({ + optional: { + TEST_ENV_VAR: "default", + TEST_ENV_VAR_2: undefined, + UNDEFINED_TEST_ENV_VAR: "default", + }, + }); + expect(env).toEqual({ + TEST_ENV_VAR: "test", + TEST_ENV_VAR_2: undefined, + UNDEFINED_TEST_ENV_VAR: "default", + }); + }); + + it("works with a mix of required and optional env vars", () => { + process.env.TEST_ENV_VAR = "test"; + process.env.TEST_ENV_VAR_2 = "test"; + const env = getEnv({ + required: ["TEST_ENV_VAR"], + optional: { + TEST_ENV_VAR_2: "default", + UNDEFINED_TEST_ENV_VAR: "default", + }, + }); + expect(env).toEqual({ + TEST_ENV_VAR: "test", + TEST_ENV_VAR_2: "test", + UNDEFINED_TEST_ENV_VAR: "default", + }); + }); + + it("returns an empty object if no env vars are requested", () => { + const env = getEnv({}); + expect(env).toEqual({}); + }); +}); diff --git a/packages/mongodb-rag-core/src/getEnv.ts b/packages/mongodb-rag-core/src/getEnv.ts new file mode 100644 index 000000000..654023959 --- /dev/null +++ b/packages/mongodb-rag-core/src/getEnv.ts @@ -0,0 +1,56 @@ +interface GetEnvArgs< + R extends string, + O extends Record +> { + /** + A list of environment variables that are required. + If any of these are missing, an error will be thrown. + */ + required?: readonly R[]; + /** + An object of environment variables that are optional. + If any of these are missing, they will default to the value provided. + */ + optional?: O; +} + +// Helper type to determine the type of an optional env var based on its default value +type OptionalEnvType = T extends undefined + ? string | undefined + : string; + +type EnvFromArgs< + R extends string, + O extends Record +> = { + [K in R]: string; +} & { + [K in keyof O]: OptionalEnvType; +}; + +export function getEnv< + R extends string = never, + O extends Record = Record +>({ required, optional }: GetEnvArgs): EnvFromArgs { + const env = { ...optional }; + const missingRequired: string[] = []; + if (required) { + required.forEach((key) => { + env[key] = process.env[key]; + if (!env[key]) { + missingRequired.push(key); + } + }); + } + if (missingRequired.length > 0) { + throw new Error( + `Missing required environment variables:\n${missingRequired + .map((r) => ` - ${r}`) + .join("\n")}` + ); + } + for (const key in optional) { + env[key] = process.env[key] ?? optional[key]; + } + return env as EnvFromArgs; +} diff --git a/packages/mongodb-rag-core/tsconfig.build.json b/packages/mongodb-rag-core/tsconfig.build.json index cc97a540d..a1118f686 100644 --- a/packages/mongodb-rag-core/tsconfig.build.json +++ b/packages/mongodb-rag-core/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["**/*.test.ts", "test/**/*.ts"] + "exclude": ["**/*.test.ts", "**/*.test-d.ts", "test/**/*.ts"] }