Last active
May 15, 2025 19:13
-
-
Save vabatta/9f70de3d945c6fcd35d7826659729249 to your computer and use it in GitHub Desktop.
NestJS Config service with zod schema and file support
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ConfigModule, ConfigService } from '@nestjs/config'; | |
import { Test } from '@nestjs/testing'; | |
import { z } from 'zod'; | |
import { registerConfig } from './config'; | |
describe('config', () => { | |
it('should register config with ConfigModule.forFeature', async () => { | |
const config = registerConfig('app', z.object({}), {}); | |
const moduleRef = await Test.createTestingModule({ | |
imports: [ConfigModule.forFeature(config)], | |
}).compile(); | |
const configService = moduleRef.get(ConfigService); | |
expect(configService).toBeDefined(); | |
}); | |
it('should return the correct config', async () => { | |
const config = registerConfig( | |
'app', | |
z.object({ | |
port: z.coerce | |
.number() | |
.positive() | |
.min(0) | |
.max(65535) | |
.default(9556) | |
.describe('The local HTTP port to bind the server to'), | |
}), | |
{}, | |
); | |
const moduleRef = await Test.createTestingModule({ | |
imports: [ConfigModule.forFeature(config)], | |
}).compile(); | |
const configService = moduleRef.get(ConfigService); | |
expect(configService.get('app')).toEqual({ | |
port: 9556, | |
}); | |
}); | |
it('should throw an error if the config is invalid', async () => { | |
const config = registerConfig( | |
'app', | |
z.object({ | |
missing: z.string().describe('This is a required field'), | |
}), | |
{ | |
APP_NOT_MISSING: 'not missing', | |
}, | |
); | |
const moduleRef = Test.createTestingModule({ | |
imports: [ | |
ConfigModule.forRoot({ ignoreEnvFile: true, load: [] }), | |
ConfigModule.forFeature(config), | |
], | |
}).compile(); | |
await expect(moduleRef).rejects.toThrow(); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import process from 'node:process'; | |
import { ConfigObject, ConfigType as NestConfigType, registerAs } from '@nestjs/config'; | |
import { merge } from 'lodash'; | |
import { CamelCase, JsonValue } from 'type-fest'; | |
import { z, ZodType, ZodTypeDef } from 'zod'; | |
import { decodeConfig, decodeVariables, zodErrorToTypeError } from './internal'; | |
/** | |
* Simple type alias for config namespace. | |
*/ | |
type ConfigNamespace<T extends string> = CamelCase<T>; | |
/** | |
* The flattened type of the config. | |
* | |
* @example | |
* const config = registerConfig('app', z.object({})); | |
* type AppConfig = ConfigType<typeof config>; | |
* // ^? { port: number } | |
*/ | |
export type ConfigType<T extends (...args: unknown[]) => unknown> = NestConfigType<T>; | |
/** | |
* The type of the config for a given namespace. | |
* | |
* @example | |
* const config = registerConfig('app', z.object({})); | |
* type AppConfig = NamespacedConfigType<typeof config>; | |
* // ^? { app: { port: number } } | |
*/ | |
export type NamespacedConfigType< | |
// needed to prevent circular dependency during infer of the config shape | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
R extends ReturnType<typeof registerConfig<string, any, any>>, | |
> = { | |
[K in R['NAMESPACE']]: NestConfigType<R>; | |
}; | |
/** | |
* Registers a config with the `ConfigModule.forFeature` for partial configuration under the provided namespace. | |
* | |
* This function handles configuration by validating and merging values from multiple sources: | |
* - **Environment Variables:** Reads environment variables, using the namespace prefix and validating them against the provided schema. | |
* - **Configuration File:** Optionally reads configuration from a file or inline YAML content, specified by the `CONFIG_FILE` or `CONFIG_CONTENT` environment variables respectively. | |
* | |
* **Merge order is Config File < Enviornment Variables** thus enviornment variables _override_ whatever the config sets. | |
* | |
* **NOTE:** Be mindful that your schema will be validated against data coming both from environment variables and/or config file, so design your schema accoringly to handle types correctly. | |
* | |
* @param namespace - The namespace of the config | |
* @param configSchema - The schema of the config | |
* @param variables - The environment variables - defaults to `process.env` | |
* | |
* @throws {TypeError} If the environment variables or configuration file content do not match the schema. | |
* | |
* @example | |
* const ConfigSchema = z.object({ | |
* port: z.number().int().min(0).max(65535).default(9558).describe('The local HTTP port to bind the server to'), | |
* }); | |
* | |
* export const appConfig = registerConfig('app', ConfigSchema); | |
* | |
* export type AppConfigNamespaced = NamespacedConfigType<typeof appConfig>; | |
* export type AppConfig = ConfigType<typeof appConfig>; | |
*/ | |
export function registerConfig<N extends string, C extends ConfigObject, I extends JsonValue>( | |
namespace: ConfigNamespace<N>, | |
configSchema: ZodType<C, ZodTypeDef, I>, | |
variables: Record<string, string | undefined> = process.env, | |
) { | |
// needed to carry the namespace to the type system | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
const service = registerAs(namespace, async () => { | |
const [decodedEnv, envKeys] = decodeVariables(variables, namespace); | |
const decodedConfig = decodeConfig(variables.CONFIG_CONTENT, variables.CONFIG_FILE); | |
const namespacedSchema = z.object({ | |
[namespace]: configSchema, | |
}); | |
const parsedConfig = await namespacedSchema.safeParseAsync( | |
merge({ [namespace]: {} }, decodedConfig, decodedEnv), | |
); | |
if (!parsedConfig.success) | |
throw zodErrorToTypeError(parsedConfig.error, namespacedSchema, namespace, envKeys); | |
const config: C = parsedConfig.data[namespace]; | |
return config; | |
}) as ReturnType<typeof registerAs<C>> & { NAMESPACE: N }; | |
// we add the namespace to the object itself for runtime access too | |
return Object.defineProperty(service, 'NAMESPACE', { value: namespace, writable: false }); | |
} | |
export default registerConfig; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fs from 'node:fs'; | |
import { | |
camelCase, | |
get, | |
head, | |
isEmpty, | |
isObjectLike, | |
isString, | |
isUndefined, | |
pickBy, | |
reduce, | |
set, | |
snakeCase, | |
tail, | |
} from 'lodash'; | |
import { JsonValue } from 'type-fest'; | |
import yaml from 'yaml'; | |
import { z, ZodEffects, ZodObject, ZodTransformer, ZodTypeAny } from 'zod'; | |
/** | |
* Raw content of the config file as UTF-8. | |
*/ | |
let cachedConfigFileContent: string | undefined; | |
/** | |
* Parsed YAML file - unknown as we don't know what shape it will have until we validate it. | |
*/ | |
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | |
let parsedConfigFileContent: unknown | undefined; | |
/** | |
* Parses a string content into a JavaScript object using YAML parser. | |
* Uses a cached result if the content has been parsed before. | |
* | |
* @param content - The string content in YAML format to parse | |
* @returns The parsed JavaScript object representation of the YAML content | |
*/ | |
function parseConfig(content: string): unknown { | |
if (!isUndefined(parsedConfigFileContent)) return parsedConfigFileContent; | |
parsedConfigFileContent = yaml.parse(content); | |
return parsedConfigFileContent; | |
} | |
/** | |
* Reads and parses a configuration file from the filesystem. | |
* Uses cached file content if available to prevent multiple filesystem reads. | |
* | |
* @param path - The path to the configuration file | |
* @returns The parsed JavaScript object representation of the file | |
* @throws {Error} if the file cannot be read or parsed | |
*/ | |
function parseConfigFile(path: string) { | |
try { | |
if (!isString(cachedConfigFileContent)) | |
cachedConfigFileContent = fs.readFileSync(path).toString('utf8'); | |
return parseConfig(cachedConfigFileContent); | |
} catch (err) { | |
const message = err instanceof Error ? err.message : new String(err).toString(); | |
throw new Error(`Unable to open the config file provided: ${message}`, { cause: err }); | |
} | |
} | |
/** | |
* Reads configuration from either a string content or a file path. | |
* String content takes precedence over file path to avoid unnecessary filesystem operations. | |
* | |
* @param content - Optional string content in YAML format | |
* @param file - Optional path to a configuration file | |
* @returns The parsed configuration as a record of string keys to JSON values | |
* @throws {Error} if neither content nor file contains a valid configuration object | |
*/ | |
export function decodeConfig(content?: string, file?: string) { | |
let readConfig: unknown = {}; | |
// NOTE: `content` takes precedence over a `file` (to avoid a file system read in case of both) | |
if (isString(content)) readConfig = parseConfig(content); | |
else if (isString(file)) readConfig = parseConfigFile(file); | |
if (!isObjectLike(readConfig)) | |
throw new Error(`Config file provided must contain the JSON configuration object`); | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
return readConfig as Record<string, JsonValue>; | |
} | |
/** | |
* Transforms environment variable keys into nested object notation starting from the provided namespace. | |
* For example, transforms "APP_SERVER__HOST" into "app.server.host" using camelCase ("APP" was the env namespace). | |
* | |
* @param envKey - The environment variable key to transform | |
* @param envNamespace - The namespace prefix to replace with a dot notation | |
* @returns A string in dot notation following camelCase conventions | |
*/ | |
function nestedConventionNamespaced(envKey: string, envNamespace: string): string { | |
return envKey | |
.replace(`${envNamespace}_`, `${envNamespace}.`) | |
.toLowerCase() | |
.replace(/__/g, '.') | |
.replace(/[a-z_]+/g, (word) => camelCase(word)); | |
} | |
/** | |
* Attempts to parse a string value as JSON, falling back to the original string if parsing fails. | |
* Used for converting environment variables that might contain JSON values to mimic config file read from ENVs as well. | |
* | |
* @param value - The string value to parse as JSON | |
* @returns The parsed JSON value or the original string if parsing fails | |
*/ | |
function jsonify(value: string | undefined): JsonValue | undefined { | |
try { | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
return JSON.parse(value ?? '') as JsonValue; | |
} catch { | |
return value; | |
} | |
} | |
/** | |
* Transforms environment variables from a flat structure with a specific namespace prefix | |
* into a nested object structure with camelCase keys, while preserving the original keys | |
* for error reporting. | |
* | |
* For example, transforms: | |
* { | |
* "MY_APP_SERVER__HOST": "localhost", | |
* "MY_APP_DATABASE__PORT": "5432", | |
* "UNRELATED_VAR": "value" | |
* } | |
* | |
* With namespace "myApp" into: | |
* { | |
* "my_app": { | |
* "server": { | |
* "host": "localhost" | |
* }, | |
* "database": { | |
* "port": 5432 // Note: jsonify attempts to parse values | |
* } | |
* } | |
* } | |
* | |
* @param variables - Record of environment variables with string keys and string values | |
* @param namespace - The namespace prefix to filter variables (will be converted to SNAKE_CASE) | |
* @returns A tuple containing: | |
* 1. The transformed nested configuration object | |
* 2. A Map relating the transformed path keys to original environment variable names for error reporting | |
*/ | |
export function decodeVariables( | |
variables: Record<string, string | undefined>, | |
namespace: string, | |
): readonly [Record<string, JsonValue | undefined>, Map<string, string>] { | |
const envKeys = new Map<string, string>(); | |
const envNamespace = snakeCase(namespace).toUpperCase(); | |
const relevantEnv = pickBy(variables, (value, key) => key.startsWith(envNamespace)); | |
const decodedEnv = reduce( | |
relevantEnv, | |
(env, value, key) => { | |
const newKey = nestedConventionNamespaced(key, envNamespace); | |
envKeys.set(newKey, key); | |
const newValue = jsonify(value); | |
return set(env, newKey, newValue); | |
}, | |
{} as Record<string, JsonValue | undefined>, | |
); | |
return [decodedEnv, envKeys]; | |
} | |
/** | |
* Recursively navigates through a Zod schema to find description metadata at a specific path. | |
* Handles various Zod schema types including effects, transformers, and objects. | |
* | |
* @param path - Array of string keys representing the path in the schema | |
* @param schema - The Zod schema to search through | |
* @returns The description string if found, otherwise undefined | |
*/ | |
function findDescriptionInSchemaByPath(path: string[], schema: ZodTypeAny) { | |
// we have to work with ZodTypeAny to accept anything, so we disable the unsafe argument check | |
if (isEmpty(path)) return schema?.description; | |
else if (schema instanceof ZodEffects) | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
return findDescriptionInSchemaByPath(path, schema.innerType()); | |
else if (schema instanceof ZodTransformer) | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
return findDescriptionInSchemaByPath(path, schema.innerType()); | |
else if (schema instanceof ZodObject) | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
return findDescriptionInSchemaByPath(tail(path), get(schema.shape, head(path) ?? '')); | |
else return schema?.description; | |
} | |
/** | |
* Converts Zod validation errors into a more descriptive TypeError. | |
* Enhances error messages with schema descriptions when available. | |
* | |
* @param error - The Zod error containing validation issues | |
* @param schema - The namespaced schema that was used for validation | |
* @param namespace - The configuration namespace for context in error messages | |
* @param keys - Map of path strings to user-friendly key names for better error reporting | |
* @returns A TypeError with formatted error messages for each validation issue | |
*/ | |
export function zodErrorToTypeError( | |
error: z.ZodError, | |
schema: ZodTypeAny, | |
namespace: string, | |
keys: Map<string, string>, | |
isSchemaNamespaced: boolean = true, | |
) { | |
const errorMessages = error.issues.map((err) => { | |
const pathNotation = err.path.join('.'); | |
const path = keys.get(pathNotation) ?? pathNotation; | |
const message = err.message; | |
const description = findDescriptionInSchemaByPath( | |
err.path.map((v) => v.toString()), | |
schema, | |
); | |
return { path, message, description }; | |
}); | |
return new ZodConfigError( | |
{ | |
name: namespace, | |
description: findDescriptionInSchemaByPath(isSchemaNamespaced ? [namespace] : [], schema), | |
}, | |
errorMessages, | |
); | |
} | |
/** | |
* Simple error wrapper for expressing an invalid config from a zod schema. | |
*/ | |
export class ZodConfigError extends TypeError { | |
public constructor( | |
public readonly namespace: { name: string; description: string | undefined }, | |
public readonly configErrors: readonly { | |
path: string; | |
message: string; | |
description: string | undefined; | |
}[], | |
options?: ErrorOptions, | |
) { | |
const spacing = ' '; | |
const title = `Invalid config for "${namespace.name}":\n`; | |
const description = isUndefined(namespace.description) | |
? '' | |
: `${spacing}${namespace.description}\n`; | |
const errors = configErrors.map((v) => ZodConfigError.mapError(v, spacing)).join('\n'); | |
super(title + description + errors, options); | |
} | |
private static mapError( | |
error: { path: string; message: string; description: string | undefined }, | |
spacing: string, | |
) { | |
let message = `${spacing}- ${error.path}: ${error.message}`; | |
message += isUndefined(error.description) ? '' : `\n${spacing}${error.description}`; | |
return message; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Module } from '@nestjs/common'; | |
import { Test } from '@nestjs/testing'; | |
import { z } from 'zod'; | |
import { ZodConfigurableModuleBuilder } from './module'; | |
describe('module', () => { | |
const MyOptionSchema = z | |
.object({ | |
useFlags: z.boolean().describe('Whether the use of flag is considered when connecting'), | |
clientName: z.string().min(5).describe('The client name used for connecting to the universe'), | |
}) | |
.describe('The options for connecting using the dynamic module') | |
.transform((v) => ({ ...v, supportsTransforms: false })) | |
// eslint-disable-next-line @typescript-eslint/require-await | |
.transform(async (v) => ({ ...v, supportsTransforms: true })); | |
type MyOption = z.infer<typeof MyOptionSchema>; | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, ASYNC_OPTIONS_TYPE, OPTIONS_TYPE } = | |
new ZodConfigurableModuleBuilder(MyOptionSchema) | |
.setExtras( | |
{ | |
isGlobal: false, | |
}, | |
(definition, extras) => ({ | |
...definition, | |
global: extras.isGlobal, | |
}), | |
) | |
.setClassMethodName('forRoot') | |
.build(); | |
@Module({}) | |
class DynModule extends ConfigurableModuleClass {} | |
it('should register correct options with static synchronous method', async () => { | |
const moduleRef = await Test.createTestingModule({ | |
imports: [ | |
DynModule.forRoot({ | |
useFlags: true, | |
clientName: 'some long enough client name', | |
}), | |
], | |
}).compile(); | |
const options = moduleRef.get<MyOption>(MODULE_OPTIONS_TOKEN); | |
expect(options).toMatchObject({ | |
useFlags: true, | |
clientName: 'some long enough client name', | |
supportsTransforms: true, | |
}); | |
}); | |
it('should register correct options with asynchronous method', async () => { | |
const DEFAULTS_TOKEN = 'DEFAULT_OPTIONS'; | |
const defaultsValue = { | |
useFlags: false, | |
}; | |
const moduleRef = await Test.createTestingModule({ | |
imports: [ | |
DynModule.forRootAsync({ | |
provideInjectionTokensFrom: [{ provide: DEFAULTS_TOKEN, useValue: defaultsValue }], | |
useFactory: (defaults: typeof defaultsValue) => { | |
return { | |
...defaults, | |
clientName: 'some long enough client name', | |
}; | |
}, | |
inject: [{ token: DEFAULTS_TOKEN, optional: false }], | |
}), | |
], | |
}).compile(); | |
const options = moduleRef.get<MyOption>(MODULE_OPTIONS_TOKEN); | |
expect(options).toMatchObject({ | |
useFlags: false, | |
clientName: 'some long enough client name', | |
supportsTransforms: true, | |
}); | |
}); | |
it('should throw an error when clientName is too short', async () => { | |
await expect( | |
Test.createTestingModule({ | |
imports: [ | |
DynModule.forRoot({ | |
useFlags: true, | |
clientName: 'shor', // Less than 5 characters | |
}), | |
], | |
}).compile(), | |
).rejects.toThrow(); | |
}); | |
it('should throw an error when required options are missing', async () => { | |
await expect( | |
Test.createTestingModule({ | |
imports: [ | |
// @ts-expect-error: we are passing what could be a complete wrong and unchecked object | |
DynModule.forRoot({ | |
// Missing clientName | |
useFlags: true, | |
} as unknown), | |
], | |
}).compile(), | |
).rejects.toThrow(); | |
}); | |
it('should throw an error when async factory returns invalid options', async () => { | |
const DEFAULTS_TOKEN = 'INVALID_DEFAULTS'; | |
const defaultsValue = { | |
useFlags: false, | |
}; | |
await expect( | |
Test.createTestingModule({ | |
imports: [ | |
DynModule.forRootAsync({ | |
provideInjectionTokensFrom: [{ provide: DEFAULTS_TOKEN, useValue: defaultsValue }], | |
useFactory: (defaults: typeof defaultsValue) => { | |
return { | |
...defaults, | |
clientName: 'bad', // Too short | |
}; | |
}, | |
inject: [{ token: DEFAULTS_TOKEN, optional: false }], | |
}), | |
], | |
}).compile(), | |
).rejects.toThrow(); | |
}); | |
it('should throw an error when invalid option types are provided', async () => { | |
await expect( | |
Test.createTestingModule({ | |
imports: [ | |
DynModule.forRoot({ | |
// @ts-expect-error: we are passing a wrong and unchecked field | |
useFlags: 'not-a-boolean' as unknown, // Wrong type | |
clientName: 'some long enough client name', | |
}), | |
], | |
}).compile(), | |
).rejects.toThrow(); | |
}); | |
it('should throw an error when injected token is not found and not optional', async () => { | |
const NON_EXISTENT_TOKEN = 'NON_EXISTENT_TOKEN'; | |
await expect( | |
Test.createTestingModule({ | |
imports: [ | |
DynModule.forRootAsync({ | |
useFactory: () => ({ | |
useFlags: true, | |
clientName: 'some long enough client name', | |
}), | |
inject: [{ token: NON_EXISTENT_TOKEN, optional: false }], | |
}), | |
], | |
}).compile(), | |
).rejects.toThrow(); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
ConfigurableModuleAsyncOptions, | |
ConfigurableModuleBuilder, | |
ConfigurableModuleBuilderOptions, | |
DynamicModule, | |
Provider, | |
} from '@nestjs/common'; | |
import { | |
DEFAULT_FACTORY_CLASS_METHOD_KEY, | |
DEFAULT_METHOD_KEY, | |
} from '@nestjs/common/module-utils/constants'; | |
import { isObject, keys, omit } from 'lodash'; | |
import { ZodError, ZodType, ZodTypeDef } from 'zod'; | |
import { zodErrorToTypeError } from './internal'; | |
export class ZodConfigurableModuleBuilder< | |
ModuleOptions, | |
ModuleOptionsInput = unknown, | |
StaticMethodKey extends string = typeof DEFAULT_METHOD_KEY, | |
FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, | |
ExtraModuleDefinitionOptions = object, | |
> { | |
protected base: ConfigurableModuleBuilder< | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
>; | |
private transformSet = false; | |
public constructor( | |
protected readonly schema: ZodType<ModuleOptions, ZodTypeDef, ModuleOptionsInput>, | |
protected readonly options: ConfigurableModuleBuilderOptions = {}, | |
parentBuilder?: ConfigurableModuleBuilder<ModuleOptionsInput>, | |
) { | |
this.base = new ConfigurableModuleBuilder(options, parentBuilder); | |
} | |
public setExtras<ExtraModuleDefinitionOptions>( | |
extras: ExtraModuleDefinitionOptions, | |
transformDefinition: ( | |
definition: DynamicModule, | |
extras: ExtraModuleDefinitionOptions, | |
) => DynamicModule = (def) => def, | |
) { | |
const builder = new ZodConfigurableModuleBuilder< | |
ModuleOptions, | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
// eslint-disable-next-line | |
>(this.schema, this.options, this as any); | |
// this was called and we patched | |
this.transformSet = true; | |
builder.base = this.patchTransform(extras, transformDefinition); | |
return builder; | |
} | |
private patchTransform<ExtraModuleDefinitionOptions>( | |
extras: ExtraModuleDefinitionOptions, | |
transformDefinition: ( | |
definition: DynamicModule, | |
extras: ExtraModuleDefinitionOptions, | |
) => DynamicModule = (def) => def, | |
) { | |
transformDefinition ??= (definition) => definition; | |
const existingTransform = transformDefinition; | |
transformDefinition = (definition, extraOptions) => { | |
const result = existingTransform(definition, extraOptions); | |
return this.transformModuleDefinitionWithSchema(result, extraOptions, extras); | |
}; | |
return this.base.setExtras(extras, transformDefinition); | |
} | |
public setClassMethodName<StaticMethodKey extends string>(key: StaticMethodKey) { | |
const builder = new ZodConfigurableModuleBuilder< | |
ModuleOptions, | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
// eslint-disable-next-line | |
>(this.schema, this.options, this as any); | |
builder.base = builder.base.setClassMethodName(key); | |
return builder; | |
} | |
public setFactoryMethodName<FactoryClassMethodKey extends string>(key: FactoryClassMethodKey) { | |
const builder = new ZodConfigurableModuleBuilder< | |
ModuleOptions, | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
// eslint-disable-next-line | |
>(this.schema, this.options, this as any); | |
builder.base = builder.base.setFactoryMethodName(key); | |
return builder; | |
} | |
public build(): ZodConfigurableModuleHost< | |
ModuleOptions, | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
> { | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
if (!this.transformSet) this.base = this.patchTransform({} as ExtraModuleDefinitionOptions); | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion | |
return this.base.build() as unknown as ZodConfigurableModuleHost< | |
ModuleOptions, | |
ModuleOptionsInput, | |
StaticMethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
>; | |
} | |
private transformModuleDefinitionWithSchema<ExtraModuleDefinitionOptions>( | |
definition: DynamicModule, | |
extraOptions: ExtraModuleDefinitionOptions, | |
extras: unknown, | |
): DynamicModule { | |
const isOptionProvider = (provider: Provider) => | |
'provide' in provider && provider.provide === this.options.optionsInjectionToken; | |
definition.providers = definition.providers?.map((provider) => { | |
if (isOptionProvider(provider)) { | |
if ('useFactory' in provider) { | |
const existingFactory = provider.useFactory; | |
provider.useFactory = async (...args) => { | |
try { | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
return await this.schema.parseAsync(existingFactory(...args)); | |
} catch (err) { | |
if (err instanceof ZodError) { | |
throw zodErrorToTypeError( | |
err, | |
this.schema, | |
definition.module.name, | |
new Map(), | |
false, | |
); | |
} | |
throw err; | |
} | |
}; | |
} else { | |
return { | |
provide: this.options.optionsInjectionToken!, | |
useFactory: async () => { | |
const finalOptions = isObject(extraOptions) | |
? omit(extraOptions, keys(extras)) | |
: extraOptions; | |
try { | |
return await this.schema.parseAsync(finalOptions); | |
} catch (err) { | |
if (err instanceof ZodError) { | |
throw zodErrorToTypeError( | |
err, | |
this.schema, | |
definition.module.name, | |
new Map(), | |
false, | |
); | |
} | |
throw err; | |
} | |
}, | |
}; | |
} | |
} | |
return provider; | |
}); | |
return definition; | |
} | |
} | |
type ZodConfigurableModuleCls< | |
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |
ModuleOptions, | |
ModuleOptionsInput = unknown, | |
MethodKey extends string = typeof DEFAULT_METHOD_KEY, | |
FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, | |
ExtraModuleDefinitionOptions = object, | |
> = { | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
new (): any; | |
} & Record< | |
`${MethodKey}`, | |
(options: ModuleOptionsInput & Partial<ExtraModuleDefinitionOptions>) => DynamicModule | |
> & | |
Record< | |
`${MethodKey}Async`, | |
( | |
options: ConfigurableModuleAsyncOptions<ModuleOptionsInput, FactoryClassMethodKey> & | |
Partial<ExtraModuleDefinitionOptions>, | |
) => DynamicModule | |
>; | |
interface ZodConfigurableModuleHost< | |
ModuleOptions = Record<string, unknown>, | |
ModuleOptionsInput = unknown, | |
MethodKey extends string = string, | |
FactoryClassMethodKey extends string = string, | |
ExtraModuleDefinitionOptions = object, | |
> { | |
/** | |
* Class that represents a blueprint/prototype for a configurable Nest module. | |
* This class provides static methods for constructing dynamic modules. Their names | |
* can be controlled through the "MethodKey" type argument. | |
* | |
* Your module class should inherit from this class to make the static methods available. | |
* | |
* @example | |
* ```typescript | |
* @Module({}) | |
* class IntegrationModule extends ConfigurableModuleCls { | |
* // ... | |
* } | |
* ``` | |
*/ | |
ConfigurableModuleClass: ZodConfigurableModuleCls< | |
ModuleOptions, | |
ModuleOptionsInput, | |
MethodKey, | |
FactoryClassMethodKey, | |
ExtraModuleDefinitionOptions | |
>; | |
/** | |
* Module options provider token. Can be used to inject the "options object" to | |
* providers registered within the host module. | |
*/ | |
MODULE_OPTIONS_TOKEN: string | symbol; | |
/** | |
* Can be used to auto-infer the compound "async module options" type. | |
* Note: this property is not supposed to be used as a value. | |
* | |
* @example | |
* ```typescript | |
* @Module({}) | |
* class IntegrationModule extends ConfigurableModuleCls { | |
* static module = initializer(IntegrationModule); | |
* | |
* static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { | |
* return super.registerAsync(options); | |
* } | |
* ``` | |
*/ | |
OPTIONS_TYPE: ModuleOptions & Partial<ExtraModuleDefinitionOptions>; | |
/** | |
* Can be used to auto-infer the compound "module options" type (options interface + extra module definition options). | |
* Note: this property is not supposed to be used as a value. | |
* | |
* @example | |
* ```typescript | |
* @Module({}) | |
* class IntegrationModule extends ConfigurableModuleCls { | |
* static module = initializer(IntegrationModule); | |
* | |
* static register(options: typeof OPTIONS_TYPE): DynamicModule { | |
* return super.register(options); | |
* } | |
* ``` | |
*/ | |
ASYNC_OPTIONS_TYPE: ConfigurableModuleAsyncOptions<ModuleOptions, FactoryClassMethodKey> & | |
Partial<ExtraModuleDefinitionOptions>; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment