diff --git a/docs/getting-started/application-startup.md b/docs/getting-started/application-startup.md index 0b14b10..c70055a 100644 --- a/docs/getting-started/application-startup.md +++ b/docs/getting-started/application-startup.md @@ -11,12 +11,18 @@ import { applicationConsumers } from './apps/ApplicationConsumers.js'; import { recurringSchedulers } from './apps/ApplicationSchedulers.js'; import GetUserByIdRoute from './apps/api/routes/GetUserByIdRoute.js'; +const environmentSchema = { + NODE_ENV: { defaultValue: 'local', type: 'string' }, + PORT: { defaultValue: 3000, type: 'number' }, +} as const; + const kernel = new Kernel({ + environmentSchema, sourceDirectory: 'src', servicesYamlPath: 'config/container/services.yaml', }); -kernel.loadEnvironmentVariables(process.env.NODE_ENV || 'local'); +kernel.loadEnvironmentVariables(); await kernel.dependencyInjection({ containerBuild: kernel.environment.NODE_ENV !== 'production', @@ -27,7 +33,7 @@ kernel.registerSchedulers(...recurringSchedulers); const server = new ExpressKernelServer({ kernel, - port: Number(kernel.environment.PORT ?? 3000), + port: kernel.environment.PORT, }); kernel.registerShutdownHook(() => server.close()); @@ -35,13 +41,13 @@ await server.run(); await kernel.runConsumers(); await kernel.runSchedulers(); -kernel.logger.info( - `Application running on port ${kernel.environment.PORT ?? 3000}`, -); +kernel.logger.info(`Application running on port ${kernel.environment.PORT}`); ``` `loadEnvironmentVariables()` loads `.env.local` by default when `NODE_ENV` is not set. Passing `test` loads `.env.test`; passing an empty string loads `.env`. +When a schema is configured, required variables are validated and values are +parsed before they are exposed through `kernel.environment`. `containerBuild: true` regenerates `config/container/services.yaml`. Production runtimes should usually load the generated YAML instead of rebuilding from diff --git a/docs/getting-started/package-map.md b/docs/getting-started/package-map.md index d6a63a5..d7f23c2 100644 --- a/docs/getting-started/package-map.md +++ b/docs/getting-started/package-map.md @@ -14,13 +14,13 @@ Bootstrap code chooses adapters. ## Core -| Area | Import | Purpose | -| ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------ | -| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. | -| Environment variables | `@haskou/ddd-kernel` | Loads `.env.` files and exposes `Kernel.environment`. | -| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. | -| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. | -| Kernel logger contract | `@haskou/ddd-kernel/contracts/kernel` | `KernelLogger` interface. | +| Area | Import | Purpose | +| ---------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. | +| Environment variables | `@haskou/ddd-kernel` | Loads `.env.` files and exposes typed `kernel.environment` when a schema is configured. | +| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. | +| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. | +| Kernel logger contract | `@haskou/ddd-kernel/contracts/kernel` | `KernelLogger` interface. | `dotenv`, `node-dependency-injection` and `fs-extra` are package dependencies because the core environment and DI implementations use them directly. diff --git a/docs/reference/kernel.md b/docs/reference/kernel.md index 8171e3f..bab3f4e 100644 --- a/docs/reference/kernel.md +++ b/docs/reference/kernel.md @@ -79,18 +79,29 @@ kernel-owned access point: const port = Number(Kernel.environment.HTTP_PORT ?? 3000); ``` -Applications can type known variables by augmenting `NodeJS.ProcessEnv`: +For typed access and runtime validation, pass an environment schema when +creating the kernel: ```ts -declare global { - namespace NodeJS { - interface ProcessEnv { - HTTP_PORT?: string; - } - } -} +const environmentSchema = { + ENABLE_JOBS: { defaultValue: false, type: 'boolean' }, + HTTP_PORT: { required: true, type: 'number' }, + SERVICE_NAME: { type: 'string' }, +} as const; + +const kernel = new Kernel({ environmentSchema }); + +kernel.loadEnvironmentVariables(); + +kernel.environment.HTTP_PORT; // number +kernel.environment.ENABLE_JOBS; // boolean +kernel.environment.SERVICE_NAME; // string | undefined ``` +Required variables throw `KernelEnvironmentValidationError` when they are +missing. `number` and `boolean` values are parsed after `.env` files are loaded. +Boolean values accept `true`, `false`, `1`, `0`, `yes`, `no`, `on` and `off`. + ## Dependency Injection Most applications call this once during startup: diff --git a/example/src/index.ts b/example/src/index.ts index 1f9941f..2a035b0 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -18,10 +18,15 @@ import path from 'node:path'; import GetUserByIdRoute from './apps/api/routes/GetUserByIdRoute.js'; const rootDirectory = process.cwd(); +const environmentSchema = { + NODE_ENV: { defaultValue: 'local', type: 'string' }, + PORT: { defaultValue: 3000, type: 'number' }, +} as const; // The kernel owns application lifecycle, dependency injection and shared // infrastructure such as logging. const kernel = new Kernel({ + environmentSchema, servicesYamlPath: path.resolve( rootDirectory, 'config', @@ -33,7 +38,7 @@ const kernel = new Kernel({ // Environment variables are loaded before DI/adapters read their process.env // fallbacks. With NODE_ENV unset this loads .env.local. -kernel.loadEnvironmentVariables(process.env.NODE_ENV || 'local'); +kernel.loadEnvironmentVariables(); // In development the container is rebuilt from default exports under src/. // In production it can reuse the generated services.yaml file. @@ -64,7 +69,7 @@ const requestLoggerMiddleware: RequestHandler = (request, response, next) => { const server = new ExpressKernelServer({ kernel, - port: Number(kernel.environment.PORT ?? 3000), + port: kernel.environment.PORT, }); // The Express adapter stays optional. HTTP middleware, hooks and error handlers @@ -88,9 +93,7 @@ server kernel.registerShutdownHook(() => server.close()); await server.run(); -kernel.logger.info( - `Application running on port ${kernel.environment.PORT ?? 3000}`, -); +kernel.logger.info(`Application running on port ${kernel.environment.PORT}`); // Delegate OS signals to the kernel so consumers, schedulers, servers and logs // are stopped consistently. diff --git a/example/yarn.lock b/example/yarn.lock index 22e5f87..89b91c0 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -143,7 +143,7 @@ integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== "@haskou/ddd-kernel@file:..": - version "1.0.3" + version "1.1.0" dependencies: dotenv "^16.6.1" fs-extra "^11.3.5" diff --git a/src/Kernel.ts b/src/Kernel.ts index a6778f0..25be5d0 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -12,18 +12,33 @@ import type { ServiceClass } from './infrastructure/dependency-injection/index.j import type { Initializer, Runtime } from './infrastructure/lifecycle/index.js'; import type { Scheduler } from './infrastructure/scheduler/index.js'; import type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; +import type { KernelEnvironmentForSchema } from './kernel/KernelEnvironmentForSchema.js'; +import type { KernelEnvironmentSchema } from './kernel/KernelEnvironmentSchema.js'; +import type { KernelEnvironmentValue } from './kernel/KernelEnvironmentValue.js'; import type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js'; import type { KernelOptions } from './kernel/KernelOptions.js'; import type { ShutdownCandidate } from './kernel/ShutdownCandidate.js'; import { ConsoleKernelLogger } from './adapters/kernel/index.js'; import { DependencyInjection } from './infrastructure/dependency-injection/index.js'; +import { KernelEnvironmentValidationError } from './kernel/KernelEnvironmentValidationError.js'; export type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; +export type { KernelDefaultEnvironment } from './kernel/KernelDefaultEnvironment.js'; +export type { KernelEnvironment } from './kernel/KernelEnvironment.js'; +export type { KernelEnvironmentForSchema } from './kernel/KernelEnvironmentForSchema.js'; +export type { KernelEnvironmentSchema } from './kernel/KernelEnvironmentSchema.js'; +export type { KernelEnvironmentValue } from './kernel/KernelEnvironmentValue.js'; +export type { KernelEnvironmentVariableDefinition } from './kernel/KernelEnvironmentVariableDefinition.js'; +export type { KernelEnvironmentVariablePrimitive } from './kernel/KernelEnvironmentVariablePrimitive.js'; +export type { KernelEnvironmentVariableType } from './kernel/KernelEnvironmentVariableType.js'; export type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js'; export type { KernelOptions } from './kernel/KernelOptions.js'; +export { KernelEnvironmentValidationError } from './kernel/KernelEnvironmentValidationError.js'; -export class Kernel { +export class Kernel< + TEnvironmentSchema extends KernelEnvironmentSchema | undefined = undefined, +> { private static readonly stateKey = Symbol.for( '@haskou/ddd-kernel/kernel-state', ); @@ -35,15 +50,23 @@ export class Kernel { private readonly schedulersList: Scheduler[] = []; private readonly shutdownHooks: ShutdownHook[] = []; private dependencyInjectionInstance: DependencyInjection | undefined; + private environmentVariables = + process.env as KernelEnvironmentForSchema; - private static get state(): { activeKernel?: Kernel } { + private static get state(): { + activeKernel?: Kernel; + } { const stateContainer = globalThis as typeof globalThis & { - [Kernel.stateKey]?: { activeKernel?: Kernel }; + [Kernel.stateKey]?: { + activeKernel?: Kernel; + }; }; stateContainer[Kernel.stateKey] = stateContainer[Kernel.stateKey] ?? {}; - return stateContainer[Kernel.stateKey] as { activeKernel?: Kernel }; + return stateContainer[Kernel.stateKey] as { + activeKernel?: Kernel; + }; } public static get configDirectory(): string { @@ -70,7 +93,7 @@ export class Kernel { return Kernel.getActiveKernel().logger; } - public static get active(): Kernel { + public static get active(): Kernel { return Kernel.getActiveKernel(); } @@ -90,7 +113,9 @@ export class Kernel { return path.resolve(Kernel.rootDirectory, 'src'); } - private static getActiveKernel(): Kernel { + private static getActiveKernel(): Kernel< + KernelEnvironmentSchema | undefined + > { if (!Kernel.state.activeKernel) { Kernel.state.activeKernel = new Kernel(); } @@ -98,9 +123,23 @@ export class Kernel { return Kernel.state.activeKernel; } + private static assertRequiredEnvironmentVariable( + name: string, + value: string | undefined, + schema: KernelEnvironmentSchema, + ): void { + if (schema[name]?.required === true && value === undefined) { + throw new KernelEnvironmentValidationError( + `Missing required environment variable "${name}".`, + ); + } + } + private static getEnvironmentVariablesPath( environment: string, - options: KernelEnvironmentVariablesOptions, + options: KernelEnvironmentVariablesOptions< + KernelEnvironmentSchema | undefined + >, ): string { return path.resolve( Kernel.rootDirectory, @@ -108,19 +147,110 @@ export class Kernel { ); } + private static parseBooleanEnvironmentVariable( + name: string, + value: string, + ): boolean { + if (['1', 'true', 'yes', 'on'].includes(value.toLowerCase())) { + return true; + } + + if (['0', 'false', 'no', 'off'].includes(value.toLowerCase())) { + return false; + } + + throw new KernelEnvironmentValidationError( + `Environment variable "${name}" must be a boolean.`, + ); + } + + private static parseNumberEnvironmentVariable( + name: string, + value: string, + ): number { + if (value.trim() === '') { + throw new KernelEnvironmentValidationError( + `Environment variable "${name}" must be a number.`, + ); + } + + const parsedValue = Number(value); + + if (Number.isFinite(parsedValue)) { + return parsedValue; + } + + throw new KernelEnvironmentValidationError( + `Environment variable "${name}" must be a number.`, + ); + } + + private static parseEnvironmentVariable( + name: string, + value: string, + schema: KernelEnvironmentSchema, + ): KernelEnvironmentValue { + const definition = schema[name]; + + if (definition.type === 'boolean') { + return Kernel.parseBooleanEnvironmentVariable(name, value); + } + + if (definition.type === 'number') { + return Kernel.parseNumberEnvironmentVariable(name, value); + } + + return value; + } + + private static validateEnvironmentVariables< + TSchema extends KernelEnvironmentSchema, + >(schema: TSchema): KernelEnvironmentForSchema { + const environmentVariables: Record = {}; + + for (const [name, definition] of Object.entries(schema)) { + const value = process.env[name] ?? definition.defaultValue?.toString(); + + Kernel.assertRequiredEnvironmentVariable(name, value, schema); + + if (value !== undefined) { + environmentVariables[name] = Kernel.parseEnvironmentVariable( + name, + value, + schema, + ); + } + } + + return { + ...process.env, + ...environmentVariables, + } as KernelEnvironmentForSchema; + } + public static loadEnvironmentVariables( environment?: string, - options: KernelEnvironmentVariablesOptions = {}, + options: KernelEnvironmentVariablesOptions< + KernelEnvironmentSchema | undefined + > = {}, ): DotenvConfigOutput { const environmentName = environment ?? process.env.NODE_ENV ?? 'local'; - return dotenv.config({ + const result = dotenv.config({ override: options.override, path: Kernel.getEnvironmentVariablesPath(environmentName, options), }); + + if (options.schema) { + Kernel.validateEnvironmentVariables(options.schema); + } + + return result; } - constructor(private readonly options: KernelOptions = {}) { + constructor( + private readonly options: KernelOptions = {}, + ) { this.loggerInstance = options.logger ?? new ConsoleKernelLogger(); this.dependencyInjectionInstance = options.di; Kernel.state.activeKernel = this; @@ -188,8 +318,8 @@ export class Kernel { return this.dependencyInjectionInstance; } - public get environment(): NodeJS.ProcessEnv { - return Kernel.environment; + public get environment(): KernelEnvironmentForSchema { + return this.environmentVariables; } public get logger(): KernelLogger { @@ -238,9 +368,20 @@ export class Kernel { public loadEnvironmentVariables( environment?: string, - options: KernelEnvironmentVariablesOptions = {}, + options: KernelEnvironmentVariablesOptions< + KernelEnvironmentSchema | undefined + > = {}, ): DotenvConfigOutput { - return Kernel.loadEnvironmentVariables(environment, options); + const result = Kernel.loadEnvironmentVariables(environment, { + ...options, + schema: options.schema ?? this.options.environmentSchema, + }); + + this.environmentVariables = this.options.environmentSchema + ? Kernel.validateEnvironmentVariables(this.options.environmentSchema) + : (process.env as KernelEnvironmentForSchema); + + return result; } public getRoutes(): ServiceClass[] { @@ -357,7 +498,9 @@ export class Kernel { } } -export function createKernel(options?: KernelOptions): Kernel { +export function createKernel< + TEnvironmentSchema extends KernelEnvironmentSchema | undefined = undefined, +>(options?: KernelOptions): Kernel { return new Kernel(options); } diff --git a/src/adapters/ui/express/ExpressKernelServerOptions.ts b/src/adapters/ui/express/ExpressKernelServerOptions.ts index fc77b69..43e4ff0 100644 --- a/src/adapters/ui/express/ExpressKernelServerOptions.ts +++ b/src/adapters/ui/express/ExpressKernelServerOptions.ts @@ -2,6 +2,7 @@ import type { ErrorRequestHandler, RequestHandler } from 'express'; import type { RoutingControllersOptions } from 'routing-controllers'; import type { Kernel } from '../../../Kernel.js'; +import type { KernelEnvironmentSchema } from '../../../kernel/KernelEnvironmentSchema.js'; import type { ExpressAppHook } from './ExpressAppHook.js'; import type { ExpressController } from './ExpressController.js'; import type { ExpressPhaseHook } from './ExpressPhaseHook.js'; @@ -12,7 +13,7 @@ export interface ExpressKernelServerOptions { readonly controllers?: ExpressController[]; readonly errorHandlers?: ErrorRequestHandler[]; readonly hooks?: ExpressPhaseHook[]; - readonly kernel: Kernel; + readonly kernel: Kernel; readonly middlewares?: RequestHandler[]; readonly postControllerMiddlewares?: RequestHandler[]; readonly preControllerMiddlewares?: RequestHandler[]; diff --git a/src/contracts/kernel/ConsumerExecutionContext.ts b/src/contracts/kernel/ConsumerExecutionContext.ts index 65d6032..475fede 100644 --- a/src/contracts/kernel/ConsumerExecutionContext.ts +++ b/src/contracts/kernel/ConsumerExecutionContext.ts @@ -1,4 +1,5 @@ import type { Kernel } from '../../Kernel.js'; +import type { KernelEnvironmentSchema } from '../../kernel/KernelEnvironmentSchema.js'; export interface ConsumerExecutionContext { readonly causationId?: string; @@ -6,7 +7,7 @@ export interface ConsumerExecutionContext { readonly eventId: string; readonly eventName: string; readonly exchange: string; - readonly kernel: Kernel; + readonly kernel: Kernel; readonly metadata: Readonly>; readonly rawMessage?: unknown; readonly queueName: string; diff --git a/src/kernel/KernelDefaultEnvironment.ts b/src/kernel/KernelDefaultEnvironment.ts new file mode 100644 index 0000000..8a3ced7 --- /dev/null +++ b/src/kernel/KernelDefaultEnvironment.ts @@ -0,0 +1 @@ +export type KernelDefaultEnvironment = NodeJS.ProcessEnv; diff --git a/src/kernel/KernelEnvironment.ts b/src/kernel/KernelEnvironment.ts new file mode 100644 index 0000000..e566f0e --- /dev/null +++ b/src/kernel/KernelEnvironment.ts @@ -0,0 +1,9 @@ +import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; +import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js'; +import type { KernelEnvironmentVariable } from './KernelEnvironmentVariable.js'; + +export type KernelEnvironment = { + readonly [key: string]: KernelEnvironmentValue | undefined; +} & { + readonly [TKey in keyof TSchema]: KernelEnvironmentVariable; +}; diff --git a/src/kernel/KernelEnvironmentForSchema.ts b/src/kernel/KernelEnvironmentForSchema.ts new file mode 100644 index 0000000..e887ed8 --- /dev/null +++ b/src/kernel/KernelEnvironmentForSchema.ts @@ -0,0 +1,9 @@ +import type { KernelDefaultEnvironment } from './KernelDefaultEnvironment.js'; +import type { KernelEnvironment } from './KernelEnvironment.js'; +import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; + +export type KernelEnvironmentForSchema< + TSchema extends KernelEnvironmentSchema | undefined, +> = TSchema extends KernelEnvironmentSchema + ? KernelEnvironment + : KernelDefaultEnvironment; diff --git a/src/kernel/KernelEnvironmentSchema.ts b/src/kernel/KernelEnvironmentSchema.ts new file mode 100644 index 0000000..2ec1524 --- /dev/null +++ b/src/kernel/KernelEnvironmentSchema.ts @@ -0,0 +1,5 @@ +import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; + +export type KernelEnvironmentSchema = Readonly< + Record +>; diff --git a/src/kernel/KernelEnvironmentValidationError.ts b/src/kernel/KernelEnvironmentValidationError.ts new file mode 100644 index 0000000..f4ae7f4 --- /dev/null +++ b/src/kernel/KernelEnvironmentValidationError.ts @@ -0,0 +1,6 @@ +export class KernelEnvironmentValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'KernelEnvironmentValidationError'; + } +} diff --git a/src/kernel/KernelEnvironmentValue.ts b/src/kernel/KernelEnvironmentValue.ts new file mode 100644 index 0000000..0c45cb2 --- /dev/null +++ b/src/kernel/KernelEnvironmentValue.ts @@ -0,0 +1 @@ +export type KernelEnvironmentValue = boolean | number | string; diff --git a/src/kernel/KernelEnvironmentVariable.ts b/src/kernel/KernelEnvironmentVariable.ts new file mode 100644 index 0000000..7d1f6da --- /dev/null +++ b/src/kernel/KernelEnvironmentVariable.ts @@ -0,0 +1,17 @@ +import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; +import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; +import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; + +export type KernelEnvironmentVariable< + TDefinition extends KernelEnvironmentVariableDefinition, +> = + TDefinition extends KernelEnvironmentVariableDefinition< + infer TType extends KernelEnvironmentVariableType, + infer TRequired extends boolean + > + ? TDefinition extends { readonly defaultValue: unknown } + ? KernelEnvironmentVariablePrimitive + : TRequired extends true + ? KernelEnvironmentVariablePrimitive + : KernelEnvironmentVariablePrimitive | undefined + : never; diff --git a/src/kernel/KernelEnvironmentVariableDefinition.ts b/src/kernel/KernelEnvironmentVariableDefinition.ts new file mode 100644 index 0000000..25b0cf4 --- /dev/null +++ b/src/kernel/KernelEnvironmentVariableDefinition.ts @@ -0,0 +1,11 @@ +import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; +import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; + +export interface KernelEnvironmentVariableDefinition< + TType extends KernelEnvironmentVariableType = KernelEnvironmentVariableType, + TRequired extends boolean = boolean, +> { + readonly defaultValue?: KernelEnvironmentVariablePrimitive; + readonly required?: TRequired; + readonly type: TType; +} diff --git a/src/kernel/KernelEnvironmentVariablePrimitive.ts b/src/kernel/KernelEnvironmentVariablePrimitive.ts new file mode 100644 index 0000000..3d8225b --- /dev/null +++ b/src/kernel/KernelEnvironmentVariablePrimitive.ts @@ -0,0 +1,9 @@ +import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; + +export type KernelEnvironmentVariablePrimitive< + TType extends KernelEnvironmentVariableType, +> = TType extends 'boolean' + ? boolean + : TType extends 'number' + ? number + : string; diff --git a/src/kernel/KernelEnvironmentVariableType.ts b/src/kernel/KernelEnvironmentVariableType.ts new file mode 100644 index 0000000..52d1c60 --- /dev/null +++ b/src/kernel/KernelEnvironmentVariableType.ts @@ -0,0 +1 @@ +export type KernelEnvironmentVariableType = 'boolean' | 'number' | 'string'; diff --git a/src/kernel/KernelEnvironmentVariablesOptions.ts b/src/kernel/KernelEnvironmentVariablesOptions.ts index f428636..b24f0e0 100644 --- a/src/kernel/KernelEnvironmentVariablesOptions.ts +++ b/src/kernel/KernelEnvironmentVariablesOptions.ts @@ -1,4 +1,9 @@ -export interface KernelEnvironmentVariablesOptions { +import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; + +export interface KernelEnvironmentVariablesOptions< + TSchema extends KernelEnvironmentSchema | undefined = undefined, +> { readonly override?: boolean; readonly path?: string; + readonly schema?: TSchema; } diff --git a/src/kernel/KernelOptions.ts b/src/kernel/KernelOptions.ts index 7850132..401bd2a 100644 --- a/src/kernel/KernelOptions.ts +++ b/src/kernel/KernelOptions.ts @@ -1,8 +1,12 @@ import type { KernelLogger } from '../contracts/index.js'; import type { DependencyInjection } from '../infrastructure/dependency-injection/index.js'; +import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; -export interface KernelOptions { +export interface KernelOptions< + TEnvironmentSchema extends KernelEnvironmentSchema | undefined = undefined, +> { readonly di?: DependencyInjection; + readonly environmentSchema?: TEnvironmentSchema; readonly logger?: KernelLogger; readonly servicesYamlPath?: string; readonly sourceDirectory?: string; diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index 20747dd..d3c6f15 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -6,7 +6,11 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { createKernel, Kernel } from '../dist/index.js'; +import { + createKernel, + Kernel, + KernelEnvironmentValidationError, +} from '../dist/index.js'; class TestConsumer { constructor(calls) { @@ -511,3 +515,219 @@ test('loads base environment file when environment name is empty', async (contex assert.equal(Kernel.environment.CUSTOM_ENV_VALUE, 'base'); }); + +test('validates typed environment schemas', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousHttpPort = process.env.HTTP_PORT; + const previousEnableJobs = process.env.ENABLE_JOBS; + const previousOptionalName = process.env.OPTIONAL_NAME; + + delete process.env.HTTP_PORT; + delete process.env.ENABLE_JOBS; + delete process.env.OPTIONAL_NAME; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + + if (previousEnableJobs === undefined) { + delete process.env.ENABLE_JOBS; + } else { + process.env.ENABLE_JOBS = previousEnableJobs; + } + + if (previousOptionalName === undefined) { + delete process.env.OPTIONAL_NAME; + } else { + process.env.OPTIONAL_NAME = previousOptionalName; + } + }); + + await writeFile( + path.join(temporaryDirectory, '.env.local'), + ['HTTP_PORT=3004', 'ENABLE_JOBS=yes'].join('\n'), + ); + + const kernel = new Kernel({ + environmentSchema: { + ENABLE_JOBS: { defaultValue: false, type: 'boolean' }, + HTTP_PORT: { required: true, type: 'number' }, + OPTIONAL_NAME: { type: 'string' }, + }, + }); + + kernel.loadEnvironmentVariables(); + + assert.equal(kernel.environment.HTTP_PORT, 3004); + assert.equal(kernel.environment.ENABLE_JOBS, true); + assert.equal(kernel.environment.OPTIONAL_NAME, undefined); +}); + +test('uses typed environment defaults when variables are absent', async (context) => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const previousDirectory = process.cwd(); + const previousEnableJobs = process.env.ENABLE_JOBS; + + delete process.env.ENABLE_JOBS; + process.chdir(temporaryDirectory); + context.after(() => { + process.chdir(previousDirectory); + + if (previousEnableJobs === undefined) { + delete process.env.ENABLE_JOBS; + } else { + process.env.ENABLE_JOBS = previousEnableJobs; + } + }); + + await writeFile(path.join(temporaryDirectory, '.env.local'), ''); + + const kernel = new Kernel({ + environmentSchema: { + ENABLE_JOBS: { defaultValue: false, type: 'boolean' }, + }, + }); + + kernel.loadEnvironmentVariables(); + + assert.equal(kernel.environment.ENABLE_JOBS, false); +}); + +test('throws when required typed environment variables are missing', () => { + const previousHttpPort = process.env.HTTP_PORT; + + delete process.env.HTTP_PORT; + + try { + const kernel = new Kernel({ + environmentSchema: { + HTTP_PORT: { required: true, type: 'number' }, + }, + }); + + assert.throws( + () => kernel.loadEnvironmentVariables(), + KernelEnvironmentValidationError, + ); + } finally { + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + } +}); + +test('throws when typed environment variables cannot be parsed', () => { + const previousHttpPort = process.env.HTTP_PORT; + const previousEnableJobs = process.env.ENABLE_JOBS; + + process.env.HTTP_PORT = 'not-a-number'; + process.env.ENABLE_JOBS = 'maybe'; + + try { + const kernel = new Kernel({ + environmentSchema: { + ENABLE_JOBS: { type: 'boolean' }, + HTTP_PORT: { type: 'number' }, + }, + }); + + assert.throws( + () => kernel.loadEnvironmentVariables(), + /Environment variable "ENABLE_JOBS" must be a boolean|Environment variable "HTTP_PORT" must be a number/, + ); + } finally { + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + + if (previousEnableJobs === undefined) { + delete process.env.ENABLE_JOBS; + } else { + process.env.ENABLE_JOBS = previousEnableJobs; + } + } +}); + +test('throws when numeric typed environment variables are blank', () => { + const previousHttpPort = process.env.HTTP_PORT; + + process.env.HTTP_PORT = ''; + + try { + const kernel = new Kernel({ + environmentSchema: { + HTTP_PORT: { type: 'number' }, + }, + }); + + assert.throws( + () => kernel.loadEnvironmentVariables(), + /Environment variable "HTTP_PORT" must be a number/, + ); + } finally { + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + } +}); + +test('throws when numeric typed environment variables are not finite numbers', () => { + const previousHttpPort = process.env.HTTP_PORT; + + process.env.HTTP_PORT = 'not-a-number'; + + try { + const kernel = new Kernel({ + environmentSchema: { + HTTP_PORT: { type: 'number' }, + }, + }); + + assert.throws( + () => kernel.loadEnvironmentVariables(), + /Environment variable "HTTP_PORT" must be a number/, + ); + } finally { + if (previousHttpPort === undefined) { + delete process.env.HTTP_PORT; + } else { + process.env.HTTP_PORT = previousHttpPort; + } + } +}); + +test('keeps typed string environment variables as strings', () => { + const previousApplicationName = process.env.APPLICATION_NAME; + + process.env.APPLICATION_NAME = 'ddd-kernel-example'; + + try { + const kernel = new Kernel({ + environmentSchema: { + APPLICATION_NAME: { type: 'string' }, + }, + }); + + kernel.loadEnvironmentVariables(); + + assert.equal(kernel.environment.APPLICATION_NAME, 'ddd-kernel-example'); + } finally { + if (previousApplicationName === undefined) { + delete process.env.APPLICATION_NAME; + } else { + process.env.APPLICATION_NAME = previousApplicationName; + } + } +});