From a740a0c43ac004d290f16752d1c60e41a1bd2ec6 Mon Sep 17 00:00:00 2001 From: Hasko Date: Mon, 29 Jun 2026 11:49:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(kernel):=20=E2=9C=A8=20Improve=20environme?= =?UTF-8?q?nt=20schema=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/getting-started/installation.md | 24 +++++++ docs/reference/kernel.md | 36 +++++++++- package.json | 2 +- src/Kernel.ts | 32 +++++++-- src/kernel/KernelEnvironmentVariable.ts | 18 +++-- .../KernelEnvironmentVariableDefinition.ts | 7 ++ tests/kernel.test.mjs | 69 +++++++++++++++++++ tests/typescript-module-resolution.test.mjs | 13 ++++ 8 files changed, 190 insertions(+), 11 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ae9ac24..8c926ea 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,26 +11,46 @@ injection and in-memory adapters. Install extra packages only for the adapters your application imports. +The core package does not load optional adapter dependencies from the root +import. Install the peer packages listed below only when importing the matching +subpath. + ## Pub/Sub Adapters The in-memory pub/sub adapter has no extra runtime dependencies. +```ts +import { InMemoryPubSub } from '@haskou/ddd-kernel/adapters/pubsub/in-memory'; +``` + The AMQP adapter uses `amqplib`: ```bash yarn add amqplib ``` +```ts +import { AmqpMessageBusAdapter } from '@haskou/ddd-kernel/adapters/pubsub/amqp'; +``` + ## DB Adapters The in-memory repository adapter has no extra runtime dependencies. +```ts +import { InMemoryRepository } from '@haskou/ddd-kernel/adapters/db/in-memory'; +``` + The MongoDB repository adapter uses `mongodb`: ```bash yarn add mongodb ``` +```ts +import { MongoRepository } from '@haskou/ddd-kernel/adapters/db/mongo'; +``` + ## UI Adapters The Express adapter uses `express`, `routing-controllers` and decorator @@ -40,6 +60,10 @@ metadata packages: yarn add express routing-controllers reflect-metadata class-transformer class-validator ``` +```ts +import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; +``` + Install `cors` only when enabling `routingControllersOptions.cors`: ```bash diff --git a/docs/reference/kernel.md b/docs/reference/kernel.md index bab3f4e..9748fda 100644 --- a/docs/reference/kernel.md +++ b/docs/reference/kernel.md @@ -84,9 +84,14 @@ creating the kernel: ```ts const environmentSchema = { - ENABLE_JOBS: { defaultValue: false, type: 'boolean' }, + ENABLE_JOBS: { + defaultValue: false, + description: 'Enables background schedulers.', + type: 'boolean', + }, HTTP_PORT: { required: true, type: 'number' }, - SERVICE_NAME: { type: 'string' }, + NODE_ENV: { choices: ['local', 'test', 'production'], type: 'string' }, + SERVICE_NAME: { sensitive: false, type: 'string' }, } as const; const kernel = new Kernel({ environmentSchema }); @@ -95,12 +100,39 @@ kernel.loadEnvironmentVariables(); kernel.environment.HTTP_PORT; // number kernel.environment.ENABLE_JOBS; // boolean +kernel.environment.NODE_ENV; // 'local' | 'test' | 'production' | undefined 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`. +Blank numeric values are rejected instead of being coerced to `0`. + +`choices` restricts the allowed runtime values and narrows the TypeScript type +when the schema is declared `as const`: + +```ts +const environmentSchema = { + NODE_ENV: { choices: ['local', 'test'], type: 'string' }, +} as const; + +const kernel = new Kernel({ environmentSchema }); + +kernel.environment.NODE_ENV; // 'local' | 'test' | undefined +``` + +Schema entries also accept metadata for generated documentation and operational +tools: + +| Field | Purpose | +| -------------- | ----------------------------------------------------------------------- | +| `type` | Runtime parser and TypeScript primitive: `string`, `number`, `boolean`. | +| `required` | Throws when the variable is missing. | +| `defaultValue` | Used when the variable is absent. | +| `choices` | Restricts accepted values and narrows the inferred TypeScript type. | +| `description` | Human-readable explanation for generated docs or audits. | +| `sensitive` | Marks values that should not be logged or displayed by tooling. | ## Dependency Injection diff --git a/package.json b/package.json index d080ec4..32ce738 100644 --- a/package.json +++ b/package.json @@ -244,7 +244,7 @@ "prepack": "yarn build", "test": "yarn build && c8 node --test \"tests/**/*.test.mjs\"", "build:coverage": "rm -rf dist && tsc -p tsconfig.coverage.json", - "test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/**/*.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"", + "test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [ diff --git a/src/Kernel.ts b/src/Kernel.ts index 25be5d0..b528973 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -1,8 +1,8 @@ import dotenv, { type DotenvConfigOutput } from 'dotenv'; import path from 'node:path'; -import type { Consumer } from './adapters/pubsub/index.js'; -import type { Route } from './adapters/ui/routes/index.js'; +import type { Consumer } from './adapters/pubsub/Consumer.js'; +import type { Route } from './adapters/ui/routes/Route.js'; import type { ConsumerMiddleware, KernelLogger, @@ -135,6 +135,20 @@ export class Kernel< } } + private static assertEnvironmentVariableChoice( + name: string, + value: KernelEnvironmentValue, + schema: KernelEnvironmentSchema, + ): void { + const choices = schema[name]?.choices; + + if (choices && !choices.includes(value)) { + throw new KernelEnvironmentValidationError( + `Environment variable "${name}" must be one of: ${choices.join(', ')}.`, + ); + } + } + private static getEnvironmentVariablesPath( environment: string, options: KernelEnvironmentVariablesOptions< @@ -193,13 +207,23 @@ export class Kernel< const definition = schema[name]; if (definition.type === 'boolean') { - return Kernel.parseBooleanEnvironmentVariable(name, value); + const parsedValue = Kernel.parseBooleanEnvironmentVariable(name, value); + + Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema); + + return parsedValue; } if (definition.type === 'number') { - return Kernel.parseNumberEnvironmentVariable(name, value); + const parsedValue = Kernel.parseNumberEnvironmentVariable(name, value); + + Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema); + + return parsedValue; } + Kernel.assertEnvironmentVariableChoice(name, value, schema); + return value; } diff --git a/src/kernel/KernelEnvironmentVariable.ts b/src/kernel/KernelEnvironmentVariable.ts index 7d1f6da..b896343 100644 --- a/src/kernel/KernelEnvironmentVariable.ts +++ b/src/kernel/KernelEnvironmentVariable.ts @@ -1,3 +1,4 @@ +import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js'; import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; @@ -7,11 +8,20 @@ export type KernelEnvironmentVariable< > = TDefinition extends KernelEnvironmentVariableDefinition< infer TType extends KernelEnvironmentVariableType, - infer TRequired extends boolean + infer TRequired extends boolean, + infer TChoices extends readonly KernelEnvironmentValue[] | undefined > ? TDefinition extends { readonly defaultValue: unknown } - ? KernelEnvironmentVariablePrimitive + ? TChoices extends readonly KernelEnvironmentVariablePrimitive[] + ? TChoices[number] + : KernelEnvironmentVariablePrimitive : TRequired extends true - ? KernelEnvironmentVariablePrimitive - : KernelEnvironmentVariablePrimitive | undefined + ? TChoices extends readonly KernelEnvironmentVariablePrimitive[] + ? TChoices[number] + : KernelEnvironmentVariablePrimitive + : + | (TChoices extends readonly KernelEnvironmentVariablePrimitive[] + ? TChoices[number] + : KernelEnvironmentVariablePrimitive) + | undefined : never; diff --git a/src/kernel/KernelEnvironmentVariableDefinition.ts b/src/kernel/KernelEnvironmentVariableDefinition.ts index 25b0cf4..d6a508c 100644 --- a/src/kernel/KernelEnvironmentVariableDefinition.ts +++ b/src/kernel/KernelEnvironmentVariableDefinition.ts @@ -1,11 +1,18 @@ +import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js'; import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; export interface KernelEnvironmentVariableDefinition< TType extends KernelEnvironmentVariableType = KernelEnvironmentVariableType, TRequired extends boolean = boolean, + TChoices extends readonly KernelEnvironmentValue[] | undefined = + | readonly KernelEnvironmentVariablePrimitive[] + | undefined, > { + readonly choices?: TChoices; readonly defaultValue?: KernelEnvironmentVariablePrimitive; + readonly description?: string; readonly required?: TRequired; + readonly sensitive?: boolean; readonly type: TType; } diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index d3c6f15..9f340ca 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -731,3 +731,72 @@ test('keeps typed string environment variables as strings', () => { } } }); + +test('validates typed environment variable choices', () => { + const previousNodeEnvironment = process.env.NODE_ENV; + const previousHttpPort = process.env.HTTP_PORT; + const previousEnableJobs = process.env.ENABLE_JOBS; + + process.env.NODE_ENV = 'test'; + process.env.HTTP_PORT = '3000'; + process.env.ENABLE_JOBS = 'false'; + + try { + const kernel = new Kernel({ + environmentSchema: { + ENABLE_JOBS: { choices: [true, false], type: 'boolean' }, + HTTP_PORT: { choices: [3000, 3001], type: 'number' }, + NODE_ENV: { choices: ['local', 'test'], type: 'string' }, + }, + }); + + kernel.loadEnvironmentVariables(); + + assert.equal(kernel.environment.NODE_ENV, 'test'); + assert.equal(kernel.environment.HTTP_PORT, 3000); + assert.equal(kernel.environment.ENABLE_JOBS, false); + } finally { + if (previousNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnvironment; + } + + 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 typed environment variable choices do not match', () => { + const previousNodeEnvironment = process.env.NODE_ENV; + + process.env.NODE_ENV = 'production'; + + try { + const kernel = new Kernel({ + environmentSchema: { + NODE_ENV: { choices: ['local', 'test'], type: 'string' }, + }, + }); + + assert.throws( + () => kernel.loadEnvironmentVariables(), + /Environment variable "NODE_ENV" must be one of: local, test/, + ); + } finally { + if (previousNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnvironment; + } + } +}); diff --git a/tests/typescript-module-resolution.test.mjs b/tests/typescript-module-resolution.test.mjs index 4891c48..21aaeb5 100644 --- a/tests/typescript-module-resolution.test.mjs +++ b/tests/typescript-module-resolution.test.mjs @@ -39,8 +39,18 @@ test('exports types for TypeScript moduleResolution node consumers', async () => import type { MessageBus, PublisherHook } from '@haskou/ddd-kernel/contracts/pubsub'; import type { DomainMessageBus } from '@haskou/ddd-kernel/domain'; import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler'; + import Kernel from '@haskou/ddd-kernel'; import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; + const environmentSchema = { + ENABLE_JOBS: { choices: [true, false], type: 'boolean' }, + HTTP_PORT: { choices: [3000, 3001], type: 'number' }, + NODE_ENV: { choices: ['local', 'test'], type: 'string' }, + } as const; + const kernel = new Kernel({ environmentSchema }); + const nodeEnvironment: 'local' | 'test' | undefined = kernel.environment.NODE_ENV; + const httpPort: 3000 | 3001 | undefined = kernel.environment.HTTP_PORT; + const enableJobs: true | false | undefined = kernel.environment.ENABLE_JOBS; const middleware: ConsumerMiddleware | undefined = undefined; const messageBus: MessageBus | undefined = undefined; const domainMessageBus: DomainMessageBus | undefined = undefined; @@ -51,6 +61,9 @@ test('exports types for TypeScript moduleResolution node consumers', async () => void messageBus; void domainMessageBus; void hook; + void nodeEnvironment; + void httpPort; + void enableJobs; void policy; void ExpressKernelServer; `,