diff --git a/README.md b/README.md index 7eda17e..0f3945e 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,17 @@ The built-in solution simply lacks behind competitors in the market. Features th ### The solution -The solution is to use the [@monkee/turbo-config](https://github.com/monke-systems/monke-turbo-config). It provides all the necessary features and is easily integrated into the NestJs application. -The starter provides a pre-configured setup of the library. All included modules are configurable. +This starter provides a high-level tool for configuring your applications. Many configuration rules are strictly enforced, and this is done intentionally. -TOOD: Add more information about the configuration +Key points: + +1. The application is configured using YML files with the ability to override settings through YML, environment variables, or CLI arguments. +2. There is always a default configuration file named config.default.yml. +3. Separate YML files can be created for each environment. The environment name is set by the NEST_CONFIG_ENV environment variable. Examples: config.production.yml, config.test.yml +4. The root directory by default is "src/resources". The root directory can be reassigned via the NEST_CONFIG_ROOT environment variable. You should properly configure the production container build +5. If the application is started with NEST_CONFIG_GENERATE_REF=true, config reference and json schema is automatically generated + +The starter uses the [@monkee/turbo-config](https://github.com/monke-systems/monke-turbo-config) library under the hood. Refer to the documentation for more details. ## Logging diff --git a/package.json b/package.json index a1112d9..a8873bd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:integration": "jest test", "lint": "eslint \"{src,test}/**/*.ts\"", "lint:fix": "eslint \"{src,test}/**/*.ts\" --fix", - "start-example-app": "NODE_ENV=development ts-node test/example-app/main.ts", + "start-example-app": "NODE_ENV=development NEST_CONFIG_ENV=dev NEST_CONFIG_GENERATE_REF=true ts-node test/example-app/main.ts", "prepublishOnly": "npm run build", "clean": "rm -rf lib" }, diff --git a/src/modules/actuator/actuator-server.ts b/src/modules/actuator/actuator-server.ts index 1de491b..944bbf4 100644 --- a/src/modules/actuator/actuator-server.ts +++ b/src/modules/actuator/actuator-server.ts @@ -58,7 +58,7 @@ export class ActuatorServer if (this.opts.config.enabled) { this.server.listen(this.opts.config.port, '0.0.0.0', () => { - this.logger.log(`Actuator is running on port ${this.opts.config.port}`); + this.logger.log(`Actuator listening on port :${this.opts.config.port}`); }); } } diff --git a/src/modules/config/config-module-opts.ts b/src/modules/config/config-module-opts.ts index 0288719..e67d9b4 100644 --- a/src/modules/config/config-module-opts.ts +++ b/src/modules/config/config-module-opts.ts @@ -1,11 +1,10 @@ export type ConfigModuleOpts = { configClass: new () => object; config: { + configRootDir: string; + configEnv?: string; + generateDefaultConfigIfNotExist?: boolean; generateDocAndSchema?: boolean; configDocPath?: string; - configSchemaPath?: string; - loadEnvFiles?: boolean; - ymlFiles?: string[]; - envFiles?: string[]; }; }; diff --git a/src/modules/config/config.module.ts b/src/modules/config/config.module.ts index f083793..873b45e 100644 --- a/src/modules/config/config.module.ts +++ b/src/modules/config/config.module.ts @@ -1,9 +1,15 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { writeFile } from 'fs/promises'; +import * as fs from 'fs/promises'; +import { writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; import type { BuildConfigOptions } from '@monkee/turbo-config'; import { buildConfig, generateConfigDoc } from '@monkee/turbo-config'; import type { DynamicModule, Provider } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; +import type { JSONSchema7 } from 'json-schema'; import type { ConfigModuleOpts } from './config-module-opts'; +import { defaultConfigText } from './default-config-text'; + +const DEFAULT_CONFIG_NAME = 'config.default.yml'; export class ConfigModule { static forRoot(opts: ConfigModuleOpts): DynamicModule { @@ -17,46 +23,8 @@ export class ConfigModule { const providers: Provider[] = [ { provide: opts.configClass, - useFactory: async () => { - const monkeeOpts: BuildConfigOptions = { - ymlFiles: opts.config.ymlFiles, - envFiles: opts.config.envFiles, - loadEnvFiles: opts.config.loadEnvFiles === true, - throwOnValidationError: false, - throwIfYmlNotExist: false, - throwIfEnvFileNotExist: false, - }; - - const { config, jsonSchema, validationErrors } = await buildConfig( - opts.configClass, - monkeeOpts, - ); - - if (opts.config.generateDocAndSchema === true) { - if (opts.config.configDocPath !== undefined) { - await generateConfigDoc(jsonSchema, { - writeToFile: opts.config.configDocPath, - }); - } - - if (opts.config.configSchemaPath !== undefined) { - await writeFile( - opts.config.configSchemaPath, - JSON.stringify(jsonSchema), - 'utf-8', - ); - } - } - - if (validationErrors.length > 0) { - throw new Error( - `Config validation failed: \n${validationErrors - .map((e) => e.toString()) - .join('\n')}`, - ); - } - - return config; + useFactory: (): Promise => { + return ConfigModule.buildConfig(opts); }, }, ]; @@ -68,4 +36,124 @@ export class ConfigModule { global, }; } + + private static async buildConfig(opts: ConfigModuleOpts): Promise { + const logger = new Logger(ConfigModule.name); + + let defaultConfigPath: string | undefined; + try { + defaultConfigPath = await ConfigModule.getValidatedConfigPath( + opts.config.configRootDir, + DEFAULT_CONFIG_NAME, + ); + } catch (e) { + if (opts.config.generateDefaultConfigIfNotExist === true) { + logger.warn( + `Default config file not found: ${DEFAULT_CONFIG_NAME}. Generating empty config file`, + ); + await ConfigModule.generateDefaultConfigFile(opts.config.configRootDir); + defaultConfigPath = await ConfigModule.getValidatedConfigPath( + opts.config.configRootDir, + DEFAULT_CONFIG_NAME, + ); + } else { + throw e; + } + } + + const ymlFiles = [defaultConfigPath]; + + if (opts.config.configEnv !== undefined) { + const envConfigPath = await ConfigModule.getValidatedConfigPath( + opts.config.configRootDir, + `config.${opts.config.configEnv}.yml`, + ); + + ymlFiles.push(envConfigPath); + } + + const monkeeOpts: BuildConfigOptions = { + ymlFiles, + envFiles: [], + loadEnvFiles: false, + throwOnValidationError: false, + throwIfYmlNotExist: false, + throwIfEnvFileNotExist: false, + }; + + const { config, jsonSchema, validationErrors } = await buildConfig( + opts.configClass, + monkeeOpts, + ); + + if (opts.config.generateDocAndSchema === true) { + if (opts.config.configDocPath !== undefined) { + await generateConfigDoc(jsonSchema, { + writeToFile: opts.config.configDocPath, + }); + } + + const configSchemaPath = path.resolve( + opts.config.configRootDir, + 'config.schema.json', + ); + + ConfigModule.makeAllJsonSchemaFieldsOptional(jsonSchema); + + await writeFile(configSchemaPath, JSON.stringify(jsonSchema), 'utf-8'); + } + + if (validationErrors.length > 0) { + throw new Error( + `Config validation failed: \n${validationErrors + .map((e) => e.toString()) + .join('\n')}`, + ); + } + + return config; + } + + private static async getValidatedConfigPath( + rootDir: string, + fileName: string, + ): Promise { + const fullPath = path.resolve(rootDir, fileName); + + try { + await fs.access(fullPath, fs.constants.F_OK); + } catch (e) { + throw new Error( + `Nest config file not found or access denied: ${fullPath}`, + ); + } + + return fullPath; + } + + private static async generateDefaultConfigFile(rootDir: string) { + const defaultConfigPath = path.resolve(rootDir, DEFAULT_CONFIG_NAME); + + await fs.mkdir(rootDir, { recursive: true }); + + await writeFile(defaultConfigPath, defaultConfigText, 'utf-8'); + } + + private static makeAllJsonSchemaFieldsOptional = (schema: JSONSchema7) => { + if (schema.required !== undefined) { + schema.required = undefined; + } + + if (schema.properties !== undefined) { + for (const key of Object.keys(schema.properties)) { + const prop = schema.properties[key] as JSONSchema7; + + if (prop.type === 'object') { + ConfigModule.makeAllJsonSchemaFieldsOptional(prop); + } else { + prop.required = undefined; + } + } + } + }; } diff --git a/src/modules/config/default-config-text.ts b/src/modules/config/default-config-text.ts new file mode 100644 index 0000000..183685c --- /dev/null +++ b/src/modules/config/default-config-text.ts @@ -0,0 +1,5 @@ +export const defaultConfigText = `# yaml-language-server: $schema=config.schema.json + +nest: + +`; diff --git a/src/starter/starter-config.ts b/src/starter/starter-config.ts index 89409cc..86a01bb 100644 --- a/src/starter/starter-config.ts +++ b/src/starter/starter-config.ts @@ -15,12 +15,6 @@ export class LoggingConfig implements LoggingModuleConfig { @ConfigField() @IsEnum(LOG_LEVEL) level!: LOG_LEVEL; - - @ConfigField() - enableHttpRequestContext!: boolean; - - @ConfigField() - enableHttpTracing!: boolean; } export class GracefulShutdownConfig implements GracefulShutdownModuleConfig { diff --git a/src/starter/starter-modules.ts b/src/starter/starter-modules.ts index 5ba21ad..d815000 100644 --- a/src/starter/starter-modules.ts +++ b/src/starter/starter-modules.ts @@ -4,7 +4,7 @@ import { ClsModule } from 'nestjs-cls'; import { ActuatorModule, ActuatorService } from '../modules/actuator'; import { ConfigModule } from '../modules/config'; import { HealthcheckModule } from '../modules/healthcheck'; -import { LoggingModule } from '../modules/logging/logging.module'; +import { LoggingModule } from '../modules/logging'; import { PrometheusModule, PrometheusRegistry } from '../modules/prometheus'; import type { NestStarterConfig } from './starter-config'; @@ -15,13 +15,11 @@ export const createStarterModules = ( ConfigModule.forRoot({ configClass, config: { - ymlFiles: [ - 'src/resources/default-config.yml', - 'resources/default-config.yml', - ], - generateDocAndSchema: process.env.NODE_ENV === 'development', + configRootDir: process.env.NEST_CONFIG_ROOT ?? 'src/resources', + configEnv: process.env.NEST_CONFIG_ENV, + generateDocAndSchema: process.env.NEST_CONFIG_GENERATE_REF === 'true', + generateDefaultConfigIfNotExist: process.env.NODE_ENV === 'development', configDocPath: 'CONFIG_REFERENCE.MD', - configSchemaPath: 'src/resources/config-schema.json', }, }), ActuatorModule.forRootAsync({