diff --git a/.vscode/launch.json b/.vscode/launch.json index cbb25084..b272357e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,12 @@ "type": "node", "request": "launch", "runtimeExecutable": "bun", - "runtimeArgs": ["--inspect-wait", "run", "test"], + "runtimeArgs": [ + "--inspect-wait", + "run", + "test", + "config-builder.test.ts" + ], "cwd": "${workspaceFolder}/packages/config", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", diff --git a/packages/config/src/config-builder.test.ts b/packages/config/src/config-builder.test.ts index 8552e872..8e21ff16 100644 --- a/packages/config/src/config-builder.test.ts +++ b/packages/config/src/config-builder.test.ts @@ -10,15 +10,39 @@ import { ObjectSource } from "./sources/object"; const builders = [ { ConfigBuilder: ClientModule.ConfigBuilder, mode: "Client" }, - { - ConfigBuilder: ServerModule.ConfigBuilder, - mode: "Server", - }, + // { + // ConfigBuilder: ServerModule.ConfigBuilder, + // mode: "Server", + // }, ]; describe.each(builders)( "[Shared Features] ConfigBuilder ($mode)", ({ ConfigBuilder }) => { + it.only("foo", () => { + expect( + new ConfigBuilder({ + validate: (finalConfig) => finalConfig, + runtimeEnv: { + PORT_2: 8888, + }, + }) + .addSource( + new ObjectSource({ + test: true, + port: "${PORT}", + alternativePort: "${self.port::-3001}", + alternativePort2: "${ALTERNATIVE_PORT::self.randomPort::PORT_2}", + }), + ) + .build(), + ).toEqual({ + port: undefined, + alternativePort: "3001", + alternativePort2: "8888", + }); + }); + it("should build config correctly (complex case)", () => { const config = new ConfigBuilder({ validate: (finalConfig) => finalConfig, @@ -36,10 +60,11 @@ describe.each(builders)( .addSource( new ObjectSource({ server: { - port: "${PORT}", + test: "${self.security.jwtSecret}", + port: "${PORT::-8080}", hostname: "${HOSTNAME::-localhost}", protocol: "${PROTOCOL::SCHEME::-http}", - baseUrl: "${BASE_URL::self.server.hostname}:${PORT::-8080}", + baseUrl: "${BASE_URL::self.server.hostname}:${self.server.port}", endpoints: [ { name: "health", @@ -56,9 +81,9 @@ describe.each(builders)( database: { host: "${DB_HOST::PRIMARY_DB_HOST::SECONDARY_DB_HOST::-db.local}", port: "${DB_PORT::-5432}", + name: "${DB_NAME::-appdb}", user: "${DB_USER::-appuser}", password: "${DB_PASS::-secret}", - name: "${DB_NAME::-appdb}", pool: { min: 2, max: "${DB_POOL_MAX::-10}", @@ -101,7 +126,7 @@ describe.each(builders)( }, }, selfReference: { - port: "${PORT}", + port: "${self.server.port}", hostname: "${HOSTNAME::-localhost}", url: "${self.selfReference.hostname}:${self.selfReference.port}", }, @@ -111,9 +136,10 @@ describe.each(builders)( expect(config).toEqual({ server: { + test: "changeme", port: "3000", - protocol: "https", hostname: "app.example.com", + protocol: "https", baseUrl: "app.example.com:3000", endpoints: [ { name: "health", path: "/health", enabled: true }, @@ -121,9 +147,9 @@ describe.each(builders)( ], }, database: { + host: "primary.db.example.com", port: "5432", user: "appuser", - host: "primary.db.example.com", password: "supersecret", name: "appdb", pool: { diff --git a/packages/config/src/sources/source.ts b/packages/config/src/sources/source.ts index 7dcb7de8..4283ab5e 100644 --- a/packages/config/src/sources/source.ts +++ b/packages/config/src/sources/source.ts @@ -1,15 +1,23 @@ -import { get } from "es-toolkit/compat"; +import { flattenObject } from "es-toolkit"; +import { get, set } from "es-toolkit/compat"; import type { ClientConfigBuilderOptions, Prettify, RuntimeEnvValue, ServerConfigBuilderOptions, - UnknownArray, UnknownRecord, } from "../types"; -import { extractSlotsFromExpression, hasSlot, type Slot } from "../utils/slot"; - -const UNDEFINED_MARKER = "___UNDEFINED_MARKER___" as const; +import { + type ExtractedSlotReturn, + extractSlotsFromExpression, + hasSelfReference, + hasSlot, + type SelfReferenceSlot, +} from "../utils/slot"; + +type ObjectPath = string; +type FlattenObjectValue = string | boolean | number; +type FlattenedObject = Record; export abstract class Source> { /** @@ -21,7 +29,9 @@ export abstract class Source> { abstract loadSource(loadSourceOptions: LoadSourceOptions): Prettify; maybeReplaceSlots(options: MaybeReplaceSlotsOptions) { + // debugger; const initialObject = options.transform(options.contentString); + /** * If there's no slot, we don't need to do anything */ @@ -29,114 +39,31 @@ export abstract class Source> { return initialObject; } - const slots = this.#extractSlots( - initialObject as UnknownRecord, - options.slotPrefix, - ); - - let updatedContentString = options.contentString; - - for (const slot of slots) { - let envVarValue: RuntimeEnvValue; - - for (const reference of slot.references) { - if (reference.type === "env_var") { - envVarValue = options.runtimeEnv[reference.envVar]; - } - - if (reference.type === "self_reference") { - const partialObj = options.transform(updatedContentString); - - envVarValue = get( - partialObj, - reference.propertyPath, - ) as RuntimeEnvValue; - } - - if (envVarValue !== null && envVarValue !== undefined) { - // If we found a value for the env var, we can stop looking - break; - } - } - - if (!envVarValue && slot.fallbackValue) { - envVarValue = slot.fallbackValue; - } - - updatedContentString = updatedContentString.replaceAll( - slot.slotMatch, - envVarValue !== null && envVarValue !== undefined - ? String(envVarValue) - : UNDEFINED_MARKER, - ); - } - - const partialConfig = this.#cleanUndefinedMarkers( - options.transform(updatedContentString), - ); - - return partialConfig; - } - - #extractSlots( - value: UnknownRecord | UnknownArray, - slotPrefix: string, - ): Slot[] { - const result: Slot[] = []; - - if (Array.isArray(value)) { - for (const item of value) { - result.push(...this.#extractSlots(item as UnknownRecord, slotPrefix)); - } - } else if (typeof value === "string") { - result.push(...extractSlotsFromExpression(value, slotPrefix)); - } else if (value && typeof value === "object") { - for (const [_, v] of Object.entries(value)) { - if (typeof v === "string") { - result.push(...extractSlotsFromExpression(v, slotPrefix)); - } else { - result.push(...this.#extractSlots(v as UnknownRecord, slotPrefix)); - } - } - } - - return result; - } - - #cleanUndefinedMarkers(value: T): any { - if (value === UNDEFINED_MARKER) { - return undefined; - } - - if (typeof value === "string" && value.includes(UNDEFINED_MARKER)) { - // If it's mixed content with undefined slots, return undefined - return undefined; - } - - if (Array.isArray(value)) { - const newList: any[] = []; - - for (const item of value) { - const cleanedItem = this.#cleanUndefinedMarkers(item); - if (cleanedItem !== undefined) { - newList.push(cleanedItem); - } + const flattenedObject = flattenObject(initialObject as UnknownRecord); + + const propsWithoutSlots: FlattenedObject = {}; + const propsWithSlots: FlattenedObject = {}; + const slotsObjs: Map = new Map(); + + for (const [key, value] of Object.entries(flattenedObject) as [ + string, + FlattenObjectValue, + ][]) { + if (!hasSlot(value.toString(), options.slotPrefix)) { + propsWithoutSlots[key] = value; + } else { + propsWithSlots[key] = value; + + slotsObjs.set( + key, + extractSlotsFromExpression(value, options.slotPrefix), + ); } - - return newList; } - if (value && typeof value === "object") { - const result: UnknownRecord = {}; - - for (const [oKey, oValue] of Object.entries(value)) { - result[oKey] = this.#cleanUndefinedMarkers(oValue); - } - - return result; - } + const flattenedStr = JSON.stringify(propsWithSlots); - return value; + console.log(flattenedStr); } } diff --git a/packages/config/src/utils/slot.test.ts b/packages/config/src/utils/slot.test.ts deleted file mode 100644 index f1827338..00000000 --- a/packages/config/src/utils/slot.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it, test } from "vitest"; -import { DEFAULT_SLOT_PREFIX } from "../types"; -import { extractSlotsFromExpression, hasSlot } from "./slot"; - -describe("fn: extractSlotsFromExpression", () => { - test.each([ - { - match: "${PORT}", - fallbackValue: undefined, - references: [ - { - type: "env_var", - envVar: "PORT", - }, - ], - }, - { - match: "${PORT::-3000}", - fallbackValue: "3000", - references: [ - { - type: "env_var", - envVar: "PORT", - }, - ], - }, - { - match: "${self.hostname}", - fallbackValue: undefined, - references: [ - { - type: "self_reference", - propertyPath: "hostname", - }, - ], - }, - { - match: "${self.hostname::HOSTNAME}", - fallbackValue: undefined, - references: [ - { - type: "self_reference", - propertyPath: "hostname", - }, - { - type: "env_var", - envVar: "HOSTNAME", - }, - ], - }, - { - match: "${HOSTNAME::self.hostname}", - fallbackValue: undefined, - references: [ - { - type: "env_var", - envVar: "HOSTNAME", - }, - { - type: "self_reference", - propertyPath: "hostname", - }, - ], - }, - { - match: "${HOSTNAME::self.hostname::-localhost}", - fallbackValue: "localhost", - references: [ - { - type: "env_var", - envVar: "HOSTNAME", - }, - { - type: "self_reference", - propertyPath: "hostname", - }, - ], - }, - ])( - "Slot Match $match | Fallback $fallbackValue", - ({ match, fallbackValue, references }) => { - const slots = extractSlotsFromExpression(match, DEFAULT_SLOT_PREFIX); - for (const slot of slots) { - expect(slot.slotMatch).toBe(match); - expect(slot.fallbackValue).toBe(fallbackValue); - expect(slot.references).toStrictEqual(references); - } - }, - ); -}); - -describe("fn: hasSlot", () => { - it("should return true if slot exists", () => { - const content = "This is a test with ${FOO} and ${BAR::self.baz}"; - expect(hasSlot(content, DEFAULT_SLOT_PREFIX)).toBe(true); - }); - - it("should return false if slot does not exist", () => { - const content = "This is a test without slots"; - expect(hasSlot(content, DEFAULT_SLOT_PREFIX)).toBe(false); - }); -}); diff --git a/packages/config/src/utils/slot.ts b/packages/config/src/utils/slot.ts index 2d699468..3aa3bf9d 100644 --- a/packages/config/src/utils/slot.ts +++ b/packages/config/src/utils/slot.ts @@ -1,60 +1,46 @@ interface EnvVarSlot { type: "env_var"; envVar: string; + part: string; } -interface SelfReferenceSlot { +export interface SelfReferenceSlot { type: "self_reference"; propertyPath: string; + part: string; +} + +export interface ExtractedSlotReturn { + references: (SelfReferenceSlot | EnvVarSlot)[]; + fallbackValue?: string; + fullMatch: string; } export function extractSlotsFromExpression( - content: string, + content: string | boolean | number, slotPrefix: string, -): Slot[] { - const slots: Slot[] = []; +): ExtractedSlotReturn { const regex = getTemplateRegex(slotPrefix); let match: RegExpExecArray | null; + const references: (SelfReferenceSlot | EnvVarSlot)[] = []; + let fallbackValue: string | undefined; // biome-ignore lint/suspicious/noAssignInExpressions: easy brow, it's fine. - while ((match = regex.exec(content)) !== null) { - const [fullMatch, slotValue] = match; + while ((match = regex.exec(String(content))) !== null) { + const [_, slotValue] = match; if (!slotValue) { throw new Error("Slot value is missing"); } - slots.push(new Slot(fullMatch, slotValue)); - } - - return slots; -} - -export function hasSlot(content: string, slotPrefix: string): boolean { - const regex = getTemplateRegex(slotPrefix); - return regex.test(content); -} - -export class Slot { - #references: (SelfReferenceSlot | EnvVarSlot)[] = []; - #slotMatch: string; - #slotContent: string; - #fallbackValue: string | undefined; - #separator = "::"; + const slotParts = slotValue.split("::"); - constructor(slotMatch: string, slotContent: string) { - this.#slotMatch = slotMatch; - this.#slotContent = slotContent; - - const slotParts = this.#slotContent.split(this.#separator); - - //Check for fallback (last value starting with -) if ( slotParts.length > 1 && slotParts[slotParts.length - 1]?.startsWith("-") ) { - this.#fallbackValue = slotParts.pop()?.slice(1); + fallbackValue = slotParts.pop()?.slice(1); } for (const slotPart of slotParts) { @@ -67,30 +53,38 @@ export class Slot { ); } - this.#references.push({ + references.push({ type: "self_reference", propertyPath, + part: slotPart, }); } else { - this.#references.push({ + references.push({ type: "env_var", - envVar: slotPart.trim(), + envVar: slotPart, + part: slotPart, }); } } } - get fallbackValue(): string | undefined { - return this.#fallbackValue; - } + return { + references, + fallbackValue, + fullMatch: String(content), + }; +} - get slotMatch(): string { - return this.#slotMatch; - } +export function hasSlot( + content: string | boolean | number, + slotPrefix: string, +): boolean { + const regex = getTemplateRegex(slotPrefix); + return regex.test(String(content)); +} - get references(): (SelfReferenceSlot | EnvVarSlot)[] { - return [...this.#references]; - } +export function hasSelfReference(content: string | boolean | number): boolean { + return content.toLocaleString().includes("self."); } function getTemplateRegex(slotPrefix: string) { diff --git a/packages/parser-json5/src/__fixtures__/pem.jsonc b/packages/parser-json5/src/__fixtures__/pem.jsonc new file mode 100644 index 00000000..822de6bc --- /dev/null +++ b/packages/parser-json5/src/__fixtures__/pem.jsonc @@ -0,0 +1,3 @@ +{ + "pem": "${PEM_KEY}" +} diff --git a/packages/parser-json5/src/index.test.ts b/packages/parser-json5/src/index.test.ts index 441072d1..9cd2806d 100644 --- a/packages/parser-json5/src/index.test.ts +++ b/packages/parser-json5/src/index.test.ts @@ -72,6 +72,14 @@ describe("json5Parser", () => { getConfig().addSource(new FileSource("base.yaml")).build(), ).toThrowError(); }); + + it("should load the PEM key from the environment variable", () => { + const result = getConfig().addSource(new FileSource("pem.jsonc")).build(); + + expect(result).toEqual({ + pem: process.env.PEM_KEY, + }); + }); }); function getConfig(options?: Partial) { diff --git a/packages/parser-json5/test/setupFiles/mock-env-var.ts b/packages/parser-json5/test/setupFiles/mock-env-var.ts new file mode 100644 index 00000000..99b0c81f --- /dev/null +++ b/packages/parser-json5/test/setupFiles/mock-env-var.ts @@ -0,0 +1,5 @@ +process.env.PEM_KEY = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49 +AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw +tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag== +-----END EC PRIVATE KEY-----`; diff --git a/packages/parser-json5/vitest.config.ts b/packages/parser-json5/vitest.config.ts new file mode 100644 index 00000000..f99bf26f --- /dev/null +++ b/packages/parser-json5/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./test/setupFiles/mock-env-var.ts"], + }, +}); diff --git a/packages/parser-yaml/src/__fixtures__/base.json b/packages/parser-yaml/src/__fixtures__/base.json deleted file mode 100644 index 38940fdf..00000000 --- a/packages/parser-yaml/src/__fixtures__/base.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "appURL": "https://my-site.com", - "api": { - "port": 3000 - } -} diff --git a/packages/parser-yaml/src/__fixtures__/pem.yaml b/packages/parser-yaml/src/__fixtures__/pem.yaml new file mode 100644 index 00000000..cb549d5d --- /dev/null +++ b/packages/parser-yaml/src/__fixtures__/pem.yaml @@ -0,0 +1 @@ +pem: ${PEM_KEY} \ No newline at end of file diff --git a/packages/parser-yaml/src/index.test.ts b/packages/parser-yaml/src/index.test.ts index 195f7c81..f0a9531a 100644 --- a/packages/parser-yaml/src/index.test.ts +++ b/packages/parser-yaml/src/index.test.ts @@ -7,6 +7,8 @@ import { import { describe, expect, it } from "vitest"; import yamlParser from "./index"; +console.log(process.env.PEM_KEY); + describe("yamlParser", () => { it("should load config from .yaml file", () => { const result = getConfig().addSource(new FileSource("base.yaml")).build(); @@ -58,9 +60,17 @@ describe("yamlParser", () => { it("should throw an error if the file extension is not supported", () => { expect(() => - getConfig().addSource(new FileSource("base.json")).build(), + getConfig().addSource(new FileSource("test.json")).build(), ).toThrowError(); }); + + it("should load the PEM key from the environment variable", () => { + const result = getConfig().addSource(new FileSource("pem.yaml")).build(); + + expect(result).toEqual({ + pem: process.env.PEM_KEY, + }); + }); }); function getConfig(options?: Partial) { diff --git a/packages/parser-yaml/test/setupFiles/mock-env-var.ts b/packages/parser-yaml/test/setupFiles/mock-env-var.ts new file mode 100644 index 00000000..99b0c81f --- /dev/null +++ b/packages/parser-yaml/test/setupFiles/mock-env-var.ts @@ -0,0 +1,5 @@ +process.env.PEM_KEY = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINofexMSQApYFYPK1/oISaOG4BgHm9SEkiHRUQOcmloToAoGCCqGSM49 +AwEHoUQDQgAEoRFl5IWEgK9PTCvI8lzT1kBdvFvVw/EZzKT8XHQczrBnVSc+S8qw +tQrWvRJknz7jP0GHpvUm2GXHx6aOcbdBag== +-----END EC PRIVATE KEY-----`; diff --git a/packages/parser-yaml/vitest.config.ts b/packages/parser-yaml/vitest.config.ts new file mode 100644 index 00000000..f99bf26f --- /dev/null +++ b/packages/parser-yaml/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./test/setupFiles/mock-env-var.ts"], + }, +});