├── .nvmrc ├── .eslintignore ├── .husky ├── .gitignore ├── pre-push ├── pre-commit └── commit-msg ├── tests ├── src │ ├── dir2 │ │ ├── bad.conf │ │ ├── table.toml │ │ └── database.toml │ ├── .env.part1 │ ├── .env.part2 │ ├── dir │ │ ├── table.toml │ │ └── database.toml │ ├── dir_sub │ │ ├── table.toml │ │ └── database.toml │ ├── .part1.env │ ├── .part2.env │ ├── .env │ ├── .env.part2.toml │ ├── .env.part1.toml │ ├── .env.yaml │ ├── .config.special │ ├── .expand.env │ ├── .env.sub.yaml │ ├── .env.test.toml │ ├── .env.invalid.toml │ ├── .env-second-file.sub.yaml │ ├── .env.toml │ ├── .env.error.toml │ ├── .env-second-with-hardcoded-host-file.sub.yaml │ ├── .env-advanced.sub.yaml │ ├── .env-advanced-backward-reference.sub.yaml │ ├── .env-object-cross-referencing.sub.yaml │ ├── .env-advanced-chain-reference-wrong-value.sub.yaml │ ├── .env-self-reference.sub.yaml │ ├── .env-reference-object.sub.yaml │ ├── .env-field-name-the-same-as-env.sub.yaml │ ├── .env-circular-between2.sub.yaml │ ├── .env-advanced-self-referencing-tricky.sub.yaml │ ├── .env-reference-array-of-primitives.sub.yaml │ ├── .env-circular-between3.sub.yaml │ ├── .env-with-default.sub.yaml │ ├── .env-reference-array-of-objects.sub.yaml │ ├── config.model.ts │ └── app.module.ts ├── setup.ts └── e2e │ ├── no-config.spec.ts │ ├── validation-failed.spec.ts │ ├── default-values.spec.ts │ ├── environment.spec.ts │ ├── multiple-schemas.spec.ts │ ├── environment-substitute-failed.spec.ts │ ├── parsing-failed.spec.ts │ ├── special-format.spec.ts │ ├── environment-substitute.spec.ts │ ├── multiple-loaders.spec.ts │ ├── select-config.spec.ts │ ├── local-toml.spec.ts │ ├── directory.spec.ts │ ├── remote.spec.ts │ ├── dotenv.spec.ts │ └── yaml-file-substitutions.spec.ts ├── index.d.ts ├── index.ts ├── .prettierignore ├── lib ├── utils │ ├── index.ts │ ├── identity.util.ts │ ├── debug.util.ts │ ├── load-package.util.ts │ ├── for-each-deep.util.ts │ ├── select-config.util.ts │ └── imports.util.ts ├── interfaces │ ├── index.ts │ └── typed-config-module-options.interface.ts ├── index.ts ├── loader │ ├── index.ts │ ├── directory-loader.ts │ ├── dotenv-loader.ts │ ├── remote-loader.ts │ └── file-loader.ts └── typed-config.module.ts ├── examples ├── basic │ ├── .env.yaml │ ├── nest-cli.json │ ├── README.md │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── src │ │ ├── app.controller.ts │ │ ├── main.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ └── config.ts │ └── package.json └── preload │ ├── nest-cli.json │ ├── README.md │ ├── .env.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── src │ ├── config.module.ts │ ├── app.module.ts │ ├── app.controller.ts │ ├── app.service.ts │ ├── config.ts │ └── main.ts │ └── package.json ├── .prettierrc ├── index.js ├── .npmignore ├── tsconfig.build.json ├── .gitignore ├── renovate.json ├── jest.config.json ├── tsconfig.json ├── .eslintrc.js ├── .github ├── workflows │ ├── codeql.yml │ ├── doc.yml │ ├── build.yml │ └── release.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── LICENSE ├── OPTIONAL-DEP.md ├── package.json ├── CONTRIBUTING.md ├── changelog.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /tests/src/dir2/bad.conf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/src/.env.part1: -------------------------------------------------------------------------------- 1 | foo=bar -------------------------------------------------------------------------------- /tests/src/.env.part2: -------------------------------------------------------------------------------- 1 | baz=qux -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; 2 | -------------------------------------------------------------------------------- /tests/src/dir/table.toml: -------------------------------------------------------------------------------- 1 | name = 'table2' -------------------------------------------------------------------------------- /tests/src/dir2/table.toml: -------------------------------------------------------------------------------- 1 | name = 'table2' -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | -------------------------------------------------------------------------------- /tests/src/dir_sub/table.toml: -------------------------------------------------------------------------------- 1 | name = "${TABLE_NAME}" -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | docs 4 | pnpm-lock.yaml -------------------------------------------------------------------------------- /lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './select-config.util'; 2 | -------------------------------------------------------------------------------- /tests/src/.part1.env: -------------------------------------------------------------------------------- 1 | isAuthEnabled=true 2 | database__host=part1 -------------------------------------------------------------------------------- /tests/src/.part2.env: -------------------------------------------------------------------------------- 1 | database__port=3000 2 | database__table__name=part2 -------------------------------------------------------------------------------- /lib/utils/identity.util.ts: -------------------------------------------------------------------------------- 1 | export const identity = (value: T): T => value; 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test:cov -------------------------------------------------------------------------------- /lib/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './typed-config-module-options.interface'; 2 | -------------------------------------------------------------------------------- /tests/src/dir/database.toml: -------------------------------------------------------------------------------- 1 | host = '127.0.0.1' 2 | port = 3000 3 | 4 | [table] 5 | name = 'test' -------------------------------------------------------------------------------- /tests/src/dir2/database.toml: -------------------------------------------------------------------------------- 1 | host = '127.0.0.1' 2 | port = 3000 3 | 4 | [table] 5 | name = 'test' -------------------------------------------------------------------------------- /tests/src/dir_sub/database.toml: -------------------------------------------------------------------------------- 1 | host = '127.0.0.1' 2 | port = 3000 3 | 4 | [table] 5 | name = 'test' -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /examples/basic/.env.yaml: -------------------------------------------------------------------------------- 1 | name: root 2 | database: 3 | name: database 4 | table: 5 | name: table 6 | -------------------------------------------------------------------------------- /examples/basic/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /examples/preload/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /tests/src/.env: -------------------------------------------------------------------------------- 1 | isAuthEnabled=true 2 | database__host=test 3 | database__port=3000 4 | database__table__name=test -------------------------------------------------------------------------------- /tests/src/.env.part2.toml: -------------------------------------------------------------------------------- 1 | isAuthEnabled = true 2 | 3 | [database] 4 | host = 'host.part2' 5 | port = 3000 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Example 2 | 3 | This folder is an example project to demonstrate `Nest-Typed-Config` 4 | -------------------------------------------------------------------------------- /examples/preload/README.md: -------------------------------------------------------------------------------- 1 | # Basic Example 2 | 3 | This folder is an example project to demonstrate `Nest-Typed-Config` 4 | -------------------------------------------------------------------------------- /lib/utils/debug.util.ts: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug'; 2 | 3 | export const debug = createDebug('nest-typed-config'); 4 | -------------------------------------------------------------------------------- /tests/src/.env.part1.toml: -------------------------------------------------------------------------------- 1 | [database] 2 | host = 'host.part1' 3 | port = 3000 4 | 5 | [database.table] 6 | name = 'test' 7 | -------------------------------------------------------------------------------- /tests/src/.env.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: 127.0.0.1 4 | port: 3000 5 | table: 6 | name: test 7 | -------------------------------------------------------------------------------- /examples/preload/.env.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "127.0.0.1", 3 | "port": 26874, 4 | "route": { 5 | "app": "/app" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/src/.config.special: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: 127.0.0.1 4 | port: 3000 5 | table: 6 | name: test -------------------------------------------------------------------------------- /tests/src/.expand.env: -------------------------------------------------------------------------------- 1 | isAuthEnabled=true 2 | database__host=expand 3 | database__port=3000 4 | database__table__name=${database__host} -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "endOfLine": "auto" 6 | } 7 | -------------------------------------------------------------------------------- /tests/src/.env.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: 127.0.0.1 4 | port: 3000 5 | table: 6 | name: ${TABLE_NAME} 7 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/preload/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './typed-config.module'; 2 | export * from './interfaces'; 3 | export * from './loader'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /tests/src/.env.test.toml: -------------------------------------------------------------------------------- 1 | isAuthEnabled = false 2 | 3 | [database] 4 | host = 'test' 5 | port = 3000 6 | 7 | [database.table] 8 | name = 'test' 9 | -------------------------------------------------------------------------------- /tests/src/.env.invalid.toml: -------------------------------------------------------------------------------- 1 | isAuthEnabled = 'true' 2 | 3 | [database] 4 | host = '127.0.0.1' 5 | port = '3000' 6 | 7 | [database.table] 8 | name = 1 9 | -------------------------------------------------------------------------------- /tests/src/.env-second-file.sub.yaml: -------------------------------------------------------------------------------- 1 | databaseAlias: 2 | host: ${database.host} 3 | port: ${database.port} 4 | table: 5 | name: ${database.table.name} 6 | -------------------------------------------------------------------------------- /lib/loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-loader'; 2 | export * from './remote-loader'; 3 | export * from './dotenv-loader'; 4 | export * from './directory-loader'; 5 | -------------------------------------------------------------------------------- /tests/src/.env.toml: -------------------------------------------------------------------------------- 1 | isAuthEnabled = true 2 | 3 | [database] 4 | host = '127.0.0.1' 5 | port = 3000 6 | 7 | [database.table] 8 | name = 'test' 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/src/.env.error.toml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | 3 | [database] 4 | host: '127.0.0.1' 5 | port: 3000 6 | 7 | [database.table] 8 | name: 'test' 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/src/.env-second-with-hardcoded-host-file.sub.yaml: -------------------------------------------------------------------------------- 1 | databaseAlias: 2 | host: my-host.com 3 | port: ${database.port} 4 | table: 5 | name: ${database.table.name} 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | function __export(m) { 3 | for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; 4 | } 5 | exports.__esModule = true; 6 | __export(require('./dist')); 7 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/preload/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src/**/*"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # source 2 | lib 3 | tests 4 | index.ts 5 | package-lock.json 6 | tslint.json 7 | tsconfig.json 8 | .prettierrc 9 | 10 | # github 11 | .github 12 | CONTRIBUTING.md 13 | renovate.json 14 | 15 | # ci 16 | .circleci -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "lib", 5 | "allowJs": false 6 | }, 7 | "include": ["lib/**/*.ts"], 8 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # IDE 5 | /.idea 6 | /.awcache 7 | /.vscode 8 | 9 | # misc 10 | npm-debug.log 11 | .DS_Store 12 | 13 | # tests 14 | /test 15 | /coverage 16 | /.nyc_output 17 | 18 | # dist 19 | dist 20 | 21 | # docs 22 | docs -------------------------------------------------------------------------------- /tests/src/.env-advanced.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: 127.0.0.1 4 | port: 3000 5 | table: 6 | name: ${TABLE_NAME} 7 | 8 | databaseAlias: 9 | host: ${database.host} 10 | port: ${database.port} 11 | table: 12 | name: ${database.table.name} 13 | -------------------------------------------------------------------------------- /tests/src/.env-advanced-backward-reference.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: ${databaseAlias.host} 4 | port: 3000 5 | table: 6 | name: users 7 | 8 | databaseAlias: 9 | host: 127.0.0.1 10 | port: ${database.port} 11 | table: 12 | name: ${database.table.name} 13 | -------------------------------------------------------------------------------- /tests/src/.env-object-cross-referencing.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: localhost 4 | port: ${databaseAlias.port} 5 | table: 6 | name: ${TABLE_NAME} 7 | 8 | databaseAlias: 9 | host: ${database.host} 10 | port: 3000 11 | table: 12 | name: ${database.table.name} 13 | -------------------------------------------------------------------------------- /tests/src/.env-advanced-chain-reference-wrong-value.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: ${database.noValue} 4 | port: 3000 5 | table: 6 | name: ${TABLE_NAME} 7 | 8 | databaseAlias: 9 | host: ${databaseAlias.host} 10 | port: ${database.port} 11 | table: 12 | name: ${database.table.name} 13 | -------------------------------------------------------------------------------- /tests/src/.env-self-reference.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | # this one should fail 4 | host: ${database.host} 5 | port: 3000 6 | table: 7 | name: ${TABLE_NAME} 8 | 9 | databaseAlias: 10 | host: ${database.host} 11 | port: ${database.port} 12 | table: 13 | name: ${database.table.name} 14 | -------------------------------------------------------------------------------- /tests/src/.env-reference-object.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: ${databaseAlias.host} 4 | port: 3000 5 | table: 6 | name: ${TABLE_NAME} 7 | 8 | databaseAlias: 9 | # this one should fail 10 | host: ${database} 11 | port: ${database.port} 12 | table: 13 | name: ${${database.table.name}} 14 | -------------------------------------------------------------------------------- /tests/src/.env-field-name-the-same-as-env.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | isAuthEnabledCopy: ${isAuthEnabled} 3 | database: 4 | host: 127.0.0.1 5 | port: 3000 6 | table: 7 | name: users 8 | 9 | databaseAlias: 10 | host: ${database.host} 11 | port: ${database.port} 12 | table: 13 | name: ${database.table.name} 14 | -------------------------------------------------------------------------------- /examples/basic/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | show(): void { 10 | return this.appService.show(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/preload/src/config.module.ts: -------------------------------------------------------------------------------- 1 | import { TypedConfigModule, fileLoader, selectConfig } from 'nest-typed-config'; 2 | import { RootConfig } from './config'; 3 | 4 | export const ConfigModule = TypedConfigModule.forRoot({ 5 | schema: RootConfig, 6 | load: fileLoader(), 7 | }); 8 | 9 | export const rootConfig = selectConfig(ConfigModule, RootConfig); 10 | -------------------------------------------------------------------------------- /tests/src/.env-circular-between2.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | # this one should fail 4 | host: ${databaseAlias.host} 5 | port: 3000 6 | table: 7 | name: ${TABLE_NAME} 8 | 9 | databaseAlias: 10 | # this one should fail 11 | host: ${database.host} 12 | port: ${database.port} 13 | table: 14 | name: ${database.table.name} 15 | -------------------------------------------------------------------------------- /tests/src/.env-advanced-self-referencing-tricky.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | host: localhost 4 | port: 3000 5 | table: 6 | name: ${TABLE_NAME} 7 | 8 | databaseAlias: 9 | host: http://${database.host}:${database.port} 10 | port: ${database.port} 11 | table: 12 | name: '${databaseAlias.host}/${database.table.name}?authEnabled=${isAuthEnabled}' 13 | -------------------------------------------------------------------------------- /examples/preload/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { ConfigModule } from './config.module'; 5 | 6 | @Module({ 7 | imports: [ConfigModule], 8 | providers: [AppService], 9 | controllers: [AppController], 10 | }) 11 | export class AppModule {} 12 | -------------------------------------------------------------------------------- /tests/src/.env-reference-array-of-primitives.sub.yaml: -------------------------------------------------------------------------------- 1 | stringArray: 2 | - one 3 | - two 4 | isAuthEnabled: true 5 | database: 6 | host: ${databaseAlias.host} 7 | port: 3000 8 | table: 9 | name: ${TABLE_NAME} 10 | 11 | databaseAlias: 12 | # this one should fail 13 | host: ${stringArray} 14 | port: ${database.port} 15 | table: 16 | name: ${${database.table.name}} 17 | -------------------------------------------------------------------------------- /tests/src/.env-circular-between3.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: true 2 | database: 3 | # this one should fail 4 | host: ${databaseAlias.table.name} 5 | port: 3000 6 | table: 7 | name: ${TABLE_NAME} 8 | 9 | databaseAlias: 10 | # this one should fail 11 | host: ${database.host} 12 | port: ${database.port} 13 | table: 14 | # this one should fail 15 | name: ${databaseAlias.host} 16 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "semanticCommits": "enabled", 4 | "timezone": "Asia/Shanghai", 5 | "schedule": ["after 1am every weekday", "before 8am every weekday"], 6 | "packageRules": [ 7 | { 8 | "depTypeList": ["devDependencies"], 9 | "automerge": true 10 | } 11 | ], 12 | "extends": ["config:base"] 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/.env-with-default.sub.yaml: -------------------------------------------------------------------------------- 1 | isAuthEnabled: ${AUTH_ENABLED:-false} 2 | defaultEmptyString: ${EMPTY_STRING:-} 3 | database: 4 | host: ${DATABASE_HOST:-my-default-host.com} 5 | port: ${DATABASE_PORT:-12345} 6 | table: 7 | name: ${TABLE_NAME:-custom-name} 8 | 9 | databaseAlias: 10 | host: ${database.host} 11 | port: ${database.port} 12 | table: 13 | name: ${database.table.name} 14 | -------------------------------------------------------------------------------- /examples/preload/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { rootConfig } from './config.module'; 4 | 5 | @Controller(rootConfig.route.app) 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | show(): void { 11 | return this.appService.show(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/preload/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RootConfig } from './config'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | // inject any config or sub-config you like 7 | constructor(private config: RootConfig) {} 8 | 9 | // enjoy type safety! 10 | public show(): any { 11 | return `Your configuration is: \n${JSON.stringify(this.config, null, 4)}\n`; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/src/.env-reference-array-of-objects.sub.yaml: -------------------------------------------------------------------------------- 1 | objectArray: 2 | - name: 'name 1' 3 | age: 1 4 | - name: 'name 2' 5 | age: 2 6 | isAuthEnabled: true 7 | database: 8 | host: ${databaseAlias.host} 9 | port: 3000 10 | table: 11 | name: ${TABLE_NAME} 12 | 13 | databaseAlias: 14 | # this one should fail 15 | host: ${objectArray} 16 | port: ${database.port} 17 | table: 18 | name: ${${database.table.name}} 19 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-typed-config-example-basic", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "nest-typed-config": "latest" 8 | }, 9 | "devDependencies": { 10 | "@nestjs/cli": "^7.6.0" 11 | }, 12 | "scripts": { 13 | "start": "nest start --watch", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC" 19 | } 20 | -------------------------------------------------------------------------------- /examples/basic/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | 7 | const server = await app.listen(0); 8 | const port = server.address().port; 9 | 10 | console.log( 11 | `\nApp successfully bootstrapped. You can try running: 12 | 13 | curl http://127.0.0.1:${port}`, 14 | ); 15 | } 16 | 17 | bootstrap().catch(console.error); 18 | -------------------------------------------------------------------------------- /examples/preload/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsDefined, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class RouteConfig { 5 | @IsString() 6 | public readonly app!: string; 7 | } 8 | 9 | export class RootConfig { 10 | @IsString() 11 | public readonly host!: string; 12 | 13 | @IsNumber() 14 | public readonly port!: number; 15 | 16 | @IsDefined() 17 | @Type(() => RouteConfig) 18 | public readonly route!: RouteConfig; 19 | } 20 | -------------------------------------------------------------------------------- /tests/e2e/no-config.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { AppModule } from '../src/app.module'; 3 | 4 | describe('No config', () => { 5 | let app: INestApplication; 6 | 7 | it(`should not bootstrap when no config file is found`, async () => { 8 | expect(() => AppModule.withConfigNotFound()).toThrow( 9 | 'Failed to find configuration file', 10 | ); 11 | }); 12 | 13 | afterEach(async () => { 14 | await app?.close(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /examples/preload/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-typed-config-example-preload", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "nest-typed-config": "latest" 8 | }, 9 | "devDependencies": { 10 | "@nestjs/cli": "^7.6.0" 11 | }, 12 | "scripts": { 13 | "start": "nest start --watch", 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "keywords": [], 17 | "author": "", 18 | "license": "ISC" 19 | } 20 | -------------------------------------------------------------------------------- /lib/utils/load-package.util.ts: -------------------------------------------------------------------------------- 1 | const MISSING_REQUIRED_DEPENDENCY = (name: string, reason: string) => 2 | `The "${name}" package is missing. Please, make sure to install this library ($ npm install ${name}) to take advantage of ${reason}.`; 3 | 4 | export function loadPackage( 5 | packageName: string, 6 | context: string, 7 | requireFn: () => T, 8 | ): T { 9 | try { 10 | return requireFn(); 11 | } catch (e) { 12 | console.error(MISSING_REQUIRED_DEPENDENCY(packageName, context)); 13 | process.exit(1); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/preload/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { RootConfig } from './config'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | const { host, port, route } = app.get(RootConfig); 8 | 9 | await app.listen(port, host); 10 | 11 | console.log( 12 | `\nApp successfully bootstrapped. You can try running: 13 | 14 | curl http://${host}:${port}${route.app}`, 15 | ); 16 | } 17 | 18 | bootstrap().catch(console.error); 19 | -------------------------------------------------------------------------------- /examples/basic/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypedConfigModule, fileLoader } from 'nest-typed-config'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { RootConfig } from './config'; 6 | 7 | // Register TypedConfigModule 8 | @Module({ 9 | imports: [ 10 | TypedConfigModule.forRoot({ 11 | schema: RootConfig, 12 | load: fileLoader(), 13 | }), 14 | ], 15 | providers: [AppService], 16 | controllers: [AppController], 17 | }) 18 | export class AppModule {} 19 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".spec.ts$", 6 | "collectCoverageFrom": ["./lib/**/*.ts"], 7 | "coverageThreshold": { 8 | "global": { 9 | "branches": 100, 10 | "functions": 100, 11 | "lines": 100, 12 | "statements": 100 13 | } 14 | }, 15 | "transform": { 16 | "^.+\\.(t|j)s$": "ts-jest" 17 | }, 18 | "transformIgnorePatterns": ["/node_modules/(?!((.pnpm/)?@nestjs))"], 19 | "setupFiles": ["./tests/setup.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "strict": true, 6 | "lib": ["esnext"], 7 | "removeComments": false, 8 | "noLib": false, 9 | "emitDecoratorMetadata": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "target": "es6", 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "rootDir": ".", 16 | "skipLibCheck": true, 17 | "allowJs": true 18 | }, 19 | "include": ["lib/**/*", "tests/**/*", "examples/**/*"], 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /examples/basic/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RootConfig, DatabaseConfig, TableConfig } from './config'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | // inject any config or sub-config you like 7 | constructor( 8 | private config: RootConfig, 9 | private databaseConfig: DatabaseConfig, 10 | private tableConfig: TableConfig, 11 | ) {} 12 | 13 | // enjoy type safety! 14 | public show(): any { 15 | const out = [ 16 | `root.name: ${this.config.name}`, 17 | `root.database.name: ${this.databaseConfig.name}`, 18 | `root.database.table.name: ${this.tableConfig.name}`, 19 | ].join('\n'); 20 | 21 | return `${out}\n`; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/e2e/validation-failed.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { AppModule } from '../src/app.module'; 3 | 4 | describe('Validation failed', () => { 5 | let app: INestApplication; 6 | 7 | it(`should not bootstrap when validation fails`, async () => { 8 | expect(() => AppModule.withValidationFailed()).toThrowError( 9 | 'isAuthEnabled must be a boolean value', 10 | ); 11 | expect(() => AppModule.withValidationFailed()).toThrowError( 12 | 'name must be a string', 13 | ); 14 | expect(() => AppModule.withValidationFailed()).toThrowError( 15 | 'port must be an integer number', 16 | ); 17 | }); 18 | 19 | afterEach(async () => { 20 | await app?.close(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /examples/basic/src/config.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsDefined, IsString, ValidateNested } from 'class-validator'; 3 | 4 | // First, define the config model, validator is omitted for simplicity 5 | export class TableConfig { 6 | @IsString() 7 | public readonly name!: string; 8 | } 9 | 10 | export class DatabaseConfig { 11 | @IsString() 12 | public readonly name!: string; 13 | 14 | @Type(() => TableConfig) 15 | @ValidateNested() 16 | @IsDefined() 17 | public readonly table!: TableConfig; 18 | } 19 | 20 | export class RootConfig { 21 | @IsString() 22 | public readonly name!: string; 23 | 24 | @Type(() => DatabaseConfig) 25 | @ValidateNested() 26 | @IsDefined() 27 | public readonly database!: DatabaseConfig; 28 | } 29 | -------------------------------------------------------------------------------- /tests/e2e/default-values.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { ConfigWithDefaultValues } from '../src/config.model'; 5 | 6 | describe('Default values', () => { 7 | let app: INestApplication; 8 | 9 | it(`should use default values`, async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [AppModule.withConfigWithDefaultValues()], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | const config = app.get(ConfigWithDefaultValues); 17 | expect(config.propertyWithDefaultValue).toEqual(4321); 18 | }); 19 | 20 | afterEach(async () => { 21 | await app?.close(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin', 'prettier'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | rules: { 19 | 'prettier/prettier': 'error', 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | '@typescript-eslint/no-use-before-define': 'off', 24 | '@typescript-eslint/no-non-null-assertion': 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /tests/e2e/environment.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { DatabaseConfig } from '../src/config.model'; 5 | 6 | describe('File loader precedence', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [AppModule.withNodeEnv()], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it(`should load .env.test.toml first when NODE_ENV is test`, () => { 19 | const databaseConfig = app.get(DatabaseConfig); 20 | expect(databaseConfig.host).toBe('test'); 21 | }); 22 | 23 | afterEach(async () => { 24 | await app?.close(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /lib/utils/for-each-deep.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Iterates over elements of collection invoking iteratee for each element. 3 | * The iteratee is invoked with three arguments: (value, path). 4 | * path is an array containing keys of current value 5 | * 6 | * @param obj object to iterate over 7 | * @param iteratee The function invoked per iteration. 8 | */ 9 | export function forEachDeep( 10 | obj: Record, 11 | iteratee: (value: any, path: string[]) => void, 12 | ): void { 13 | const helper = (obj: any, path: string[]) => { 14 | Object.entries(obj).forEach(([key, value]) => { 15 | iteratee(value, [...path, key]); 16 | 17 | if (typeof value === 'object' && value && !Array.isArray(value)) { 18 | return helper(value, [...path, key]); 19 | } 20 | }); 21 | }; 22 | helper(obj, []); 23 | return; 24 | } 25 | -------------------------------------------------------------------------------- /tests/e2e/multiple-schemas.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { BazConfig, FooConfig } from '../src/config.model'; 5 | 6 | describe('Multiple config schemas', () => { 7 | let app: INestApplication; 8 | 9 | it(`should merge configs from all loaders`, async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [AppModule.withMultipleSchemas()], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | 17 | const fooConfig = app.get(FooConfig); 18 | const bazConfig = app.get(BazConfig); 19 | 20 | expect(fooConfig.foo).toBe('bar'); 21 | expect(bazConfig.baz).toBe('qux'); 22 | 23 | await app.close(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/e2e/environment-substitute-failed.spec.ts: -------------------------------------------------------------------------------- 1 | import { AppModule } from '../src/app.module'; 2 | 3 | describe('Environment variable substitutions failure', () => { 4 | it(`should load .env.yaml and should throw error`, async () => { 5 | expect(() => 6 | AppModule.withYamlSubstitution({ 7 | ignoreEnvironmentVariableSubstitution: false, 8 | }), 9 | ).toThrowError( 10 | `Environment variable is not set for variable name: 'TABLE_NAME'`, 11 | ); 12 | }); 13 | 14 | it(`should load .env.yaml and should not throw error when disallowUndefinedEnvironmentVariables is set to false`, async () => { 15 | expect(() => 16 | AppModule.withYamlSubstitution({ 17 | ignoreEnvironmentVariableSubstitution: false, 18 | disallowUndefinedEnvironmentVariables: false, 19 | }), 20 | ).not.toThrow(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/e2e/parsing-failed.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModuleBuilder } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | 5 | describe('Parsing failed', () => { 6 | let app: INestApplication; 7 | let module: TestingModuleBuilder; 8 | 9 | it(`should not bootstrap when parsing fails`, async () => { 10 | expect(() => AppModule.withErrorToml()).toThrow('TOML Error'); 11 | expect(() => AppModule.withErrorToml()).toThrow('.env.error.toml'); 12 | }); 13 | 14 | it(`should not bootstrap when config is not object`, async () => { 15 | module = Test.createTestingModule({ 16 | imports: [AppModule.withStringConfig()], 17 | }); 18 | await expect(module.compile()).rejects.toThrow( 19 | 'Configuration should be an object', 20 | ); 21 | }); 22 | 23 | afterEach(async () => { 24 | await app?.close(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | schedule: 9 | - cron: '59 10 * * 4' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: '/language:${{ matrix.language }}' 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nikaple Zhou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/utils/select-config.util.ts: -------------------------------------------------------------------------------- 1 | import type { DynamicModule, ValueProvider } from '@nestjs/common'; 2 | import type { ClassConstructor } from 'class-transformer'; 3 | 4 | export interface SelectConfigOptions { 5 | /** 6 | * when true, allow undefined config declared with `@IsOptional()` 7 | */ 8 | allowOptional?: boolean; 9 | } 10 | 11 | export const selectConfig = ( 12 | module: DynamicModule, 13 | Config: ClassConstructor, 14 | options?: Option, 15 | ): Option extends { allowOptional: true } ? T | undefined : T => { 16 | const providers = module.providers as ValueProvider[]; 17 | const selectedConfig = (providers || []).filter( 18 | ({ provide }) => provide === Config, 19 | )[0]; 20 | if (options?.allowOptional) { 21 | return selectedConfig?.useValue; 22 | } 23 | if (!selectedConfig) { 24 | throw new Error( 25 | 'You can only select config which exists in providers. \ 26 | If the config being selected is marked as optional, \ 27 | please pass `allowOptional` in options argument.', 28 | ); 29 | } 30 | return selectedConfig.useValue; 31 | }; 32 | -------------------------------------------------------------------------------- /tests/e2e/special-format.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { Config, DatabaseConfig, TableConfig } from '../src/config.model'; 5 | 6 | jest.mock('@iarna/toml', () => { 7 | throw new Error('module not found'); 8 | }); 9 | 10 | describe('Special format', () => { 11 | let app: INestApplication; 12 | 13 | beforeEach(async () => { 14 | const module = await Test.createTestingModule({ 15 | imports: [AppModule.withSpecialFormat()], 16 | }).compile(); 17 | 18 | app = module.createNestApplication(); 19 | await app.init(); 20 | }); 21 | 22 | it(`should be able to load special format through loaders`, () => { 23 | const config = app.get(Config); 24 | expect(config.isAuthEnabled).toBe(true); 25 | 26 | const databaseConfig = app.get(DatabaseConfig); 27 | expect(databaseConfig.port).toBe(3000); 28 | 29 | const tableConfig = app.get(TableConfig); 30 | expect(tableConfig.name).toBe('test'); 31 | }); 32 | 33 | afterEach(async () => { 34 | await app?.close(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /lib/utils/imports.util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import type { plainToClass as _plainToClass } from 'class-transformer'; 3 | import type { validateSync as _validateSync } from 'class-validator'; 4 | 5 | // Resolve class-validator, class-transformer from root node_modules to avoid decorator metadata conflicts 6 | let classValidatorModule: { validateSync: typeof _validateSync }; 7 | try { 8 | const classValidatorModulePath = require.resolve('class-validator', { 9 | paths: ['../..', '.'], 10 | }); 11 | classValidatorModule = require(classValidatorModulePath); 12 | } catch (e) { 13 | /* istanbul ignore next */ 14 | classValidatorModule = require('class-validator'); 15 | } 16 | export const { validateSync } = classValidatorModule; 17 | 18 | let classTransformerModule: { plainToClass: typeof _plainToClass }; 19 | try { 20 | const classTransformerModulePath = require.resolve('class-transformer', { 21 | paths: ['../..', '.'], 22 | }); 23 | classTransformerModule = require(classTransformerModulePath); 24 | } catch (e) { 25 | /* istanbul ignore next */ 26 | classTransformerModule = require('class-transformer'); 27 | } 28 | export const { plainToClass } = classTransformerModule; 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | 3 | Please check if your PR fulfills the following requirements: 4 | 5 | - [ ] The commit message follows our guidelines: https://github.com/Nikaple/nest-typed-config/blob/main/CONTRIBUTING.md 6 | - [ ] Tests for the changes have been added (for bug fixes / features) 7 | - [ ] Docs have been added / updated (for bug fixes / features) 8 | 9 | ## PR Type 10 | 11 | What kind of change does this PR introduce? 12 | 13 | 14 | 15 | - [ ] Related issue linked using `fixes #number` 16 | - [ ] Bugfix 17 | - [ ] Feature 18 | - [ ] Code style update (formatting, local variables) 19 | - [ ] Refactoring (no functional changes, no api changes) 20 | - [ ] Build related changes 21 | - [ ] CI related changes 22 | - [ ] Other... Please describe: 23 | 24 | ## What is the current behavior? 25 | 26 | 27 | 28 | Issue Number: N/A 29 | 30 | ## What is the new behavior? 31 | 32 | ## Does this PR introduce a breaking change? 33 | 34 | - [ ] Yes 35 | - [ ] No 36 | 37 | 38 | 39 | ## Other information 40 | -------------------------------------------------------------------------------- /.github/workflows/doc.yml: -------------------------------------------------------------------------------- 1 | # This workflow deploy typedoc generated documentation to gh-pages branch 2 | 3 | name: doc 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v2 21 | 22 | - name: Use Node.js 18 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: '18' 26 | 27 | - name: Install dependencies 28 | run: pnpm install 29 | 30 | - name: Lint 31 | run: pnpm run lint:dontfix 32 | 33 | - name: Check Formatting 34 | run: pnpm run format:dontfix 35 | 36 | - name: Coverage 37 | run: pnpm run test:cov 38 | 39 | - name: Generate documentation 40 | run: pnpm run doc 41 | 42 | - name: Deploy to Github Pages 43 | uses: crazy-max/ghaction-github-pages@v4.2.0 44 | with: 45 | # Build directory to deploy 46 | build_dir: docs 47 | # The author name and email address 48 | author: typedoc 49 | # Allow Jekyll to build your site 50 | jekyll: false 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | -------------------------------------------------------------------------------- /tests/e2e/environment-substitute.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { FileLoaderOptions } from '../../lib'; 4 | import { AppModule } from '../src/app.module'; 5 | import { DatabaseConfig } from '../src/config.model'; 6 | 7 | describe('Environment variable substitutions', () => { 8 | let app: INestApplication; 9 | 10 | const tableName = 'users'; 11 | 12 | const init = async (options: FileLoaderOptions) => { 13 | process.env['TABLE_NAME'] = tableName; 14 | const module = await Test.createTestingModule({ 15 | imports: [AppModule.withYamlSubstitution(options)], 16 | }).compile(); 17 | 18 | app = module.createNestApplication(); 19 | await app.init(); 20 | }; 21 | 22 | it(`should load .env.yaml and substitute environment variable`, async () => { 23 | await init({ ignoreEnvironmentVariableSubstitution: false }); 24 | const databaseConfig = app.get(DatabaseConfig); 25 | expect(databaseConfig.table.name).toBe(tableName); 26 | }); 27 | 28 | it(`should load .env.yaml and substitute environment variable`, async () => { 29 | await init({ ignoreEnvironmentVariableSubstitution: true }); 30 | const databaseConfig = app.get(DatabaseConfig); 31 | expect(databaseConfig.table.name).toBe('${TABLE_NAME}'); 32 | }); 33 | 34 | afterEach(async () => { 35 | process.env['TABLE_NAME'] = ''; 36 | await app?.close(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 9 | 12 | 13 | - [ ] Regression 14 | - [ ] Bug report 15 | - [ ] Feature request 16 | - [ ] Documentation issue or request 17 | - [ ] Support request 18 | 19 | ## Current behavior 20 | 21 | 22 | 23 | ## Expected behavior 24 | 25 | 26 | 27 | ## Minimal reproduction of the problem with instructions 28 | 29 | 30 | 31 | ## What is the motivation / use case for changing the behavior? 32 | 33 | 34 | 35 | ## Environment 36 | 37 |

38 | Nest version: X.Y.Z
39 | Nest-Typed-Config version: X.Y.Z
40 | 
41 |  
42 | For Tooling issues:
43 | - Node version: XX  
44 | - Platform:  
45 | 
46 | Others:
47 | 
48 | 
49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | 3 | name: build 4 | 5 | on: 6 | push: 7 | branches: [main, next, alpha] 8 | pull_request: 9 | branches: [main, next, alpha] 10 | 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | node-version: [14.x, 16.x, 18.x] 21 | steps: 22 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v2 28 | 29 | - name: Use Node.js ${{ matrix.node-version }} 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | registry-url: https://registry.npmjs.org/ 34 | cache: pnpm 35 | 36 | - name: Install dependencies 37 | run: pnpm install 38 | 39 | - name: Lint 40 | run: pnpm run lint:dontfix 41 | 42 | - name: Check Formatting 43 | run: pnpm run format:dontfix 44 | 45 | - name: Coverage 46 | run: pnpm run test:cov 47 | 48 | - name: Report to coveralls 49 | uses: coverallsapp/github-action@v2.3.6 50 | with: 51 | github-token: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Build 54 | run: pnpm run build 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow release nest-typed-config to npm registry 2 | 3 | name: release 4 | 5 | on: 6 | push: 7 | branches: [main, next, alpha] 8 | 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 18 | with: 19 | fetch-depth: 0 20 | persist-credentials: false 21 | 22 | - name: Setup Github Action account 23 | run: | 24 | git config --global user.name "GitHub Action" 25 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 26 | 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v2 29 | 30 | - name: Use Node.js 18 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: '18' 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: Lint 39 | run: pnpm run lint:dontfix 40 | 41 | - name: Check Formatting 42 | run: pnpm run format:dontfix 43 | 44 | - name: Coverage 45 | run: pnpm run test:cov 46 | 47 | - name: Report to coveralls 48 | uses: coverallsapp/github-action@v2.3.6 49 | with: 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | - name: Build before release 53 | run: pnpm run build 54 | 55 | - name: Release 56 | run: pnpm run release 57 | -------------------------------------------------------------------------------- /tests/e2e/multiple-loaders.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { Config, DatabaseConfig, TableConfig } from '../src/config.model'; 5 | 6 | describe('Local toml', () => { 7 | let app: INestApplication; 8 | 9 | const init = async ( 10 | option: ('reject' | 'part1' | 'part2')[], 11 | async = true, 12 | ) => { 13 | const module = await Test.createTestingModule({ 14 | imports: [AppModule.withMultipleLoaders(option, async)], 15 | }).compile(); 16 | 17 | app = module.createNestApplication(); 18 | await app.init(); 19 | }; 20 | 21 | it(`should merge configs from all loaders`, async () => { 22 | await init(['part1', 'part2']); 23 | 24 | const config = app.get(Config); 25 | expect(config.isAuthEnabled).toBe(true); 26 | 27 | const tableConfig = app.get(TableConfig); 28 | expect(tableConfig.name).toBe('test'); 29 | }); 30 | 31 | it(`should assure that loaders with largest index have highest priority`, async () => { 32 | await init(['part2', 'part1'], false); 33 | 34 | const databaseConfig = app.get(DatabaseConfig); 35 | expect(databaseConfig.host).toBe('host.part1'); 36 | }); 37 | 38 | /** 39 | * this is a subject for discussion 40 | * */ 41 | it(`should not be able load config when some of the loaders fail`, async () => { 42 | expect(init(['reject', 'part1', 'part2'])).rejects.toThrowError(); 43 | }); 44 | 45 | afterEach(async () => { 46 | await app?.close(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/e2e/select-config.spec.ts: -------------------------------------------------------------------------------- 1 | import { selectConfig } from '../../lib'; 2 | import { AppModule } from '../src/app.module'; 3 | import { Config, DatabaseConfig, TableConfig } from '../src/config.model'; 4 | 5 | describe('Local toml', () => { 6 | it(`should be able to select config`, async () => { 7 | const module = AppModule.withRawModule(); 8 | 9 | const config = selectConfig(module, Config); 10 | expect(config.isAuthEnabled).toBe(true); 11 | 12 | const databaseConfig = selectConfig(module, DatabaseConfig); 13 | expect(databaseConfig.port).toBe(3000); 14 | 15 | const tableConfig = selectConfig(module, TableConfig); 16 | expect(tableConfig.name).toBe('test'); 17 | }); 18 | 19 | it(`can only select existing config without 'allowOptional'`, async () => { 20 | const module = AppModule.withRawModule(); 21 | 22 | expect(() => selectConfig(module, class {})).toThrowError( 23 | 'You can only select config which exists in providers', 24 | ); 25 | expect(() => selectConfig({ module: class {} }, class {})).toThrowError( 26 | 'You can only select config which exists in providers', 27 | ); 28 | }); 29 | 30 | it(`can select optional config with 'allowOptional'`, async () => { 31 | const module = AppModule.withRawModule(); 32 | 33 | expect( 34 | selectConfig(module, class {}, { allowOptional: true }), 35 | ).toBeUndefined(); 36 | expect( 37 | selectConfig({ module: class {} }, class {}, { allowOptional: true }), 38 | ).toBeUndefined(); 39 | 40 | const config = selectConfig(module, Config, { allowOptional: true }); 41 | expect(config?.isAuthEnabled).toBe(true); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /lib/loader/directory-loader.ts: -------------------------------------------------------------------------------- 1 | import type { OptionsSync } from 'cosmiconfig'; 2 | import { readdirSync } from 'fs'; 3 | import { fileLoader } from './file-loader'; 4 | import fromPairs from 'lodash.frompairs'; 5 | 6 | export interface DirectoryLoaderOptions extends OptionsSync { 7 | /** 8 | * The directory containing all configuration files. 9 | */ 10 | directory: string; 11 | /** 12 | * File regex to include. 13 | */ 14 | include?: RegExp; 15 | /** 16 | * If "true", ignore environment variable substitution. 17 | * Default: true 18 | */ 19 | ignoreEnvironmentVariableSubstitution?: boolean; 20 | } 21 | 22 | /** 23 | * Directory loader loads configuration in a specific folder. 24 | * The basename of file will be used as configuration key, for the directory below: 25 | * 26 | * ``` 27 | * . 28 | * └─config 29 | * ├── app.toml 30 | * └── db.toml 31 | * ``` 32 | * 33 | * The parsed config will be `{ app: "config in app.toml", db: "config in db.toml" }` 34 | * @param options directory loader options. 35 | */ 36 | export const directoryLoader = ({ 37 | directory, 38 | ...options 39 | }: DirectoryLoaderOptions) => { 40 | return (): Record => { 41 | const files = readdirSync(directory).filter(fileName => 42 | options.include ? options.include.test(fileName) : true, 43 | ); 44 | const fileNames = [ 45 | ...new Set(files.map(file => file.replace(/\.\w+$/, ''))), 46 | ]; 47 | const configs = fromPairs( 48 | fileNames.map(name => [ 49 | name, 50 | fileLoader({ 51 | basename: name, 52 | searchFrom: directory, 53 | ...options, 54 | })(), 55 | ]), 56 | ); 57 | 58 | return configs; 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /tests/e2e/local-toml.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { Config, DatabaseConfig, TableConfig } from '../src/config.model'; 5 | 6 | jest.mock('@iarna/toml', () => { 7 | if (process.env.MISSING_TOML) { 8 | throw new Error('module not found: @iarna/toml'); 9 | } 10 | return jest.requireActual('@iarna/toml'); 11 | }); 12 | jest.mock('dotenv', () => { 13 | throw new Error('module not found: dotenv'); 14 | }); 15 | 16 | describe('Local toml', () => { 17 | let app: INestApplication; 18 | let envBackup = {}; 19 | const processExitStub = jest.fn(); 20 | const consoleErrorStub = jest.fn(); 21 | 22 | beforeEach(async () => { 23 | envBackup = process.env; 24 | process.exit = processExitStub as any; 25 | console.error = consoleErrorStub as any; 26 | 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | it(`should throw error when @iarna/toml is not installed`, async () => { 31 | process.env = { MISSING_TOML: '1' }; 32 | 33 | expect(() => AppModule.withToml()).toThrowError(); 34 | expect(processExitStub).toBeCalledTimes(1); 35 | expect(consoleErrorStub).toBeCalledTimes(1); 36 | }); 37 | 38 | it(`should be able to load toml config file`, async () => { 39 | const module = await Test.createTestingModule({ 40 | imports: [AppModule.withToml()], 41 | }).compile(); 42 | 43 | app = module.createNestApplication(); 44 | await app.init(); 45 | 46 | const config = app.get(Config); 47 | expect(config.isAuthEnabled).toBe(true); 48 | 49 | const databaseConfig = app.get(DatabaseConfig); 50 | expect(databaseConfig.port).toBe(3000); 51 | 52 | const tableConfig = app.get(TableConfig); 53 | expect(tableConfig.name).toBe('test'); 54 | }); 55 | 56 | afterEach(async () => { 57 | await app?.close(); 58 | 59 | process.env = envBackup; 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/e2e/directory.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { DirectoryConfig, DatabaseConfig } from '../src/config.model'; 5 | 6 | describe('Directory loader', () => { 7 | let app: INestApplication; 8 | 9 | it(`should be able to config from specific folder`, async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [AppModule.withDirectory()], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | const config = app.get(DirectoryConfig); 17 | expect(config.table.name).toEqual('table2'); 18 | 19 | const databaseConfig = app.get(DatabaseConfig); 20 | expect(databaseConfig.port).toBe(3000); 21 | }); 22 | 23 | it(`should be able to include specific files`, async () => { 24 | const module = await Test.createTestingModule({ 25 | imports: [AppModule.withDirectoryInclude()], 26 | }).compile(); 27 | 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | const config = app.get(DirectoryConfig); 31 | expect(config.table.name).toEqual('table2'); 32 | 33 | const databaseConfig = app.get(DatabaseConfig); 34 | expect(databaseConfig.port).toBe(3000); 35 | }); 36 | 37 | it(`should be able to substitute env variables`, async () => { 38 | process.env['TABLE_NAME'] = 'table2'; 39 | const module = await Test.createTestingModule({ 40 | imports: [AppModule.withDirectorySubstitution()], 41 | }).compile(); 42 | 43 | app = module.createNestApplication(); 44 | await app.init(); 45 | const config = app.get(DirectoryConfig); 46 | expect(config.table.name).toEqual('table2'); 47 | 48 | const databaseConfig = app.get(DatabaseConfig); 49 | expect(databaseConfig.port).toBe(3000); 50 | }); 51 | 52 | afterEach(async () => { 53 | process.env['TABLE_NAME'] = ''; 54 | await app?.close(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /lib/interfaces/typed-config-module-options.interface.ts: -------------------------------------------------------------------------------- 1 | import type { ClassConstructor } from 'class-transformer'; 2 | import type { ValidatorOptions } from 'class-validator'; 3 | 4 | export type ConfigLoader = (...args: any) => Record; 5 | export type AsyncConfigLoader = (...args: any) => Promise>; 6 | 7 | export interface TypedConfigModuleOptions { 8 | /** 9 | * The root object for application configuration. 10 | */ 11 | schema: ClassConstructor; 12 | 13 | /** 14 | * Function(s) to load configurations, must be synchronous. 15 | */ 16 | load: ConfigLoader | ConfigLoader[]; 17 | 18 | /** 19 | * Defaults to "true". 20 | * 21 | * If "true", registers `ConfigModule` as a global module. 22 | * See: https://docs.nestjs.com/modules#global-modules 23 | */ 24 | isGlobal?: boolean; 25 | 26 | /** 27 | * Custom function to normalize configurations. It takes an object containing environment 28 | * variables as input and outputs normalized configurations. 29 | * 30 | * This function is executed before validation, and can be used to do type casting, 31 | * variable expanding, etc. 32 | */ 33 | normalize?: (config: Record) => Record; 34 | 35 | /** 36 | * Custom function to validate configurations. It takes an object containing environment 37 | * variables as input and outputs validated configurations. 38 | * If exception is thrown in the function it would prevent the application from bootstrapping. 39 | */ 40 | validate?: (config: Record) => Record; 41 | 42 | /** 43 | * Options passed to validator during validation. 44 | * @see https://github.com/typestack/class-validator 45 | */ 46 | validationOptions?: ValidatorOptions; 47 | } 48 | 49 | export interface TypedConfigModuleAsyncOptions 50 | extends TypedConfigModuleOptions { 51 | /** 52 | * Function(s) to load configurations, can be synchronous or asynchronous. 53 | */ 54 | load: ConfigLoader | AsyncConfigLoader | (ConfigLoader | AsyncConfigLoader)[]; 55 | } 56 | -------------------------------------------------------------------------------- /OPTIONAL-DEP.md: -------------------------------------------------------------------------------- 1 | # Skipping optional dependencies 2 | 3 | To reduce dependency size, you can install `nest-typed-config` without optional dependencies with: 4 | 5 | ```bash 6 | # No optional dependency is installed 7 | $ npm i --no-optional nest-typed-config 8 | ``` 9 | 10 | By skipping optional dependencies, you have to install the dependencies of configuration loaders by yourself. Please checkout the installation guide for corresponding loader: 11 | 12 | - [dotenv loader](#Dotenv-loader) 13 | - [file loader](#File-loader) 14 | - [directory loader](#Directory-loader) 15 | - [remote loader](#Remote-loader) 16 | - [custom loader](#Custom-loader) 17 | 18 | # Installation guide for different loaders 19 | 20 | ## Dotenv loader 21 | 22 | To use `dotenvLoader`, you should install `dotenv` first. If you want to expand environment variables, `dotenv-expand` should be installed as well. 23 | 24 | ```bash 25 | # For NPM 26 | $ npm i --save dotenv 27 | 28 | # If you want to expand environment variables, remember to install dotenv-expand 29 | $ npm i --save dotenv dotenv-expand 30 | ``` 31 | 32 | ## File loader 33 | 34 | To use `fileLoader`, you should install `cosmiconfig` first. If you want to load `.toml` configuration files, `@iarna/toml` should be installed as well. 35 | 36 | ```bash 37 | # For NPM 38 | $ npm i --save cosmiconfig 39 | 40 | # If you want to load .toml configs, remember to install @iarna/toml 41 | $ npm i --save @iarna/toml 42 | ``` 43 | 44 | ## Directory loader 45 | 46 | To use `directoryLoader`, you should install `cosmiconfig` first. If you want to load `.toml` configuration files, `@iarna/toml` should be installed as well. 47 | 48 | ```bash 49 | # For NPM 50 | $ npm i --save cosmiconfig 51 | 52 | # If you want to load .toml configs, remember to install @iarna/toml 53 | $ npm i --save @iarna/toml 54 | ``` 55 | 56 | ## Remote loader 57 | 58 | To use `remoteLoader`, you should install `@nestjs/axios` first. Then, depending on the config file format, you should install corresponding config file parser. That is: 59 | 60 | - `parse-json` to parse `.json` configurations. 61 | - `yaml` to parse `.yml` or `.yaml` configurations. 62 | - `toml` to parse `.toml` configurations. 63 | 64 | ```bash 65 | $ npm i --save @nestjs/axios 66 | 67 | # For .toml config 68 | $ npm i --save @iarna/toml 69 | # For .json config 70 | $ npm i --save parse-json 71 | # For .yaml/.yml config 72 | $ npm i --save yml 73 | ``` 74 | 75 | ## Custom loader 76 | 77 | Just implement your own logic, no extra dependency is required! 78 | -------------------------------------------------------------------------------- /tests/src/config.model.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Transform, Type } from 'class-transformer'; 3 | import { 4 | IsBoolean, 5 | IsDefined, 6 | IsInt, 7 | IsString, 8 | ValidateNested, 9 | } from 'class-validator'; 10 | import { applyDecorators } from '@nestjs/common'; 11 | 12 | export class TableConfig { 13 | @IsString() 14 | public readonly name!: string; 15 | } 16 | 17 | export class DatabaseConfig { 18 | @IsString() 19 | public readonly host!: string; 20 | 21 | @IsInt() 22 | public readonly port!: number; 23 | 24 | @Type(() => TableConfig) 25 | @ValidateNested() 26 | @IsDefined() 27 | public readonly table!: TableConfig; 28 | } 29 | 30 | export class DatabaseConfigAlias extends DatabaseConfig { 31 | @IsInt() 32 | @Type(() => Number) 33 | public readonly port!: number; 34 | } 35 | 36 | export class DatabaseConfigAliasCopy extends DatabaseConfigAlias {} 37 | 38 | const ToBoolean = applyDecorators( 39 | Transform(({ value }) => 40 | typeof value === 'boolean' ? value : value === 'true', 41 | ), 42 | ); 43 | 44 | export class Config { 45 | @Type(() => DatabaseConfig) 46 | @ValidateNested() 47 | @IsDefined() 48 | public readonly database!: DatabaseConfig; 49 | 50 | @IsBoolean() 51 | public readonly isAuthEnabled!: boolean; 52 | } 53 | 54 | export class ConfigWithDefaultValuesForEnvs { 55 | @IsBoolean() 56 | @ToBoolean 57 | public readonly isAuthEnabled!: boolean; 58 | 59 | @IsString() 60 | public readonly defaultEmptyString!: string; 61 | 62 | @Type(() => DatabaseConfigAliasCopy) 63 | @ValidateNested() 64 | @IsDefined() 65 | public readonly database!: DatabaseConfigAliasCopy; 66 | 67 | @Type(() => DatabaseConfigAlias) 68 | @ValidateNested() 69 | @IsDefined() 70 | public readonly databaseAlias!: DatabaseConfigAlias; 71 | } 72 | 73 | export class ConfigWithAlias extends Config { 74 | @Type(() => DatabaseConfigAlias) 75 | @ValidateNested() 76 | @IsDefined() 77 | public readonly databaseAlias!: DatabaseConfigAlias; 78 | } 79 | 80 | export class DatabaseConfigWithAliasAndAuthCopy extends ConfigWithAlias { 81 | @ToBoolean 82 | @IsBoolean() 83 | public readonly isAuthEnabledCopy!: boolean; 84 | } 85 | 86 | export class DirectoryConfig { 87 | @Type(() => DatabaseConfig) 88 | @ValidateNested() 89 | @IsDefined() 90 | public readonly database!: DatabaseConfig; 91 | 92 | @Type(() => TableConfig) 93 | @ValidateNested() 94 | @IsDefined() 95 | public readonly table!: TableConfig; 96 | } 97 | 98 | export class FooConfig { 99 | @IsString() 100 | foo!: string; 101 | } 102 | 103 | export class BazConfig { 104 | @IsString() 105 | baz!: string; 106 | } 107 | 108 | export class ConfigWithDefaultValues { 109 | @IsInt() 110 | readonly propertyWithDefaultValue: number = 4321; 111 | } 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-typed-config", 3 | "version": "2.3.0", 4 | "description": "Intuitive, type-safe configuration module for Nest framework", 5 | "author": "Nikaple Zhou", 6 | "license": "MIT", 7 | "packageManager": "pnpm@7.33.7", 8 | "url": "https://github.com/Nikaple/nest-typed-config", 9 | "homepage": "https://github.com/Nikaple/nest-typed-config", 10 | "files": [ 11 | "index.js", 12 | "index.d.ts", 13 | "index.ts", 14 | "lib", 15 | "dist" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/Nikaple/nest-typed-config" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/Nikaple/nest-typed-config/issues" 23 | }, 24 | "scripts": { 25 | "prepare": "husky install", 26 | "build": "rimraf -rf dist && tsc -p tsconfig.build.json", 27 | "test": "jest --runInBand", 28 | "test:watch": "jest --runInBand --watch", 29 | "test:cov": "jest --runInBand --coverage", 30 | "doc": "typedoc lib/index.ts --tsconfig tsconfig.build.json", 31 | "lint": "eslint {lib/**/*.ts,tests/**/*.ts,examples/**/*.ts} --fix", 32 | "lint:dontfix": "eslint {lib/**/*.ts,tests/**/*.ts,examples/**/*.ts}", 33 | "format": "prettier --write .", 34 | "format:dontfix": "prettier --check .", 35 | "prepublish:npm": "npm run build", 36 | "prerelease": "npm run build", 37 | "release": "semantic-release" 38 | }, 39 | "dependencies": { 40 | "chalk": "4.1.2", 41 | "class-transformer": "0.5.1", 42 | "class-validator": "^0.14.0", 43 | "debug": "4.3.4", 44 | "lodash.frompairs": "4.0.1", 45 | "lodash.merge": "4.6.2", 46 | "set-value": "^4.1.0" 47 | }, 48 | "pnpm": { 49 | "overrides": { 50 | "rxjs": "7.8.0", 51 | "axios": "0.27.2" 52 | } 53 | }, 54 | "devDependencies": { 55 | "@commitlint/cli": "17.7.1", 56 | "@iarna/toml": "2.2.5", 57 | "@latipun7/commitlintrc": "1.1.3", 58 | "@latipun7/releaserc": "^2.1.0", 59 | "@nestjs/axios": "3.0.0", 60 | "@nestjs/cli": "10.1.17", 61 | "@nestjs/common": "10.2.5", 62 | "@nestjs/core": "10.2.5", 63 | "@nestjs/platform-express": "10.2.5", 64 | "@nestjs/testing": "10.2.5", 65 | "@types/debug": "4.1.8", 66 | "@types/express": "4.17.17", 67 | "@types/jest": "29.5.5", 68 | "@types/lodash.frompairs": "4.0.7", 69 | "@types/lodash.merge": "4.6.7", 70 | "@types/node": "18.17.17", 71 | "@types/set-value": "^4.0.1", 72 | "@typescript-eslint/eslint-plugin": "5.62.0", 73 | "@typescript-eslint/parser": "5.62.0", 74 | "axios": "1.5.0", 75 | "cosmiconfig": "8.3.6", 76 | "dotenv": "16.3.1", 77 | "dotenv-expand": "10.0.0", 78 | "eslint": "7.32.0", 79 | "eslint-config-prettier": "9.0.0", 80 | "eslint-plugin-import": "2.28.1", 81 | "eslint-plugin-prettier": "4.2.1", 82 | "husky": "8.0.3", 83 | "jest": "29.7.0", 84 | "lint-staged": "14.0.1", 85 | "parse-json": "8.3.0", 86 | "prettier": "2.8.8", 87 | "reflect-metadata": "0.1.13", 88 | "rimraf": "5.0.1", 89 | "rxjs": "7.8.1", 90 | "semantic-release": "^22.0.0", 91 | "ts-jest": "29.1.1", 92 | "typedoc": "0.25.1", 93 | "typescript": "5.2.2", 94 | "yaml": "2.3.2" 95 | }, 96 | "optionalDependencies": { 97 | "@iarna/toml": ">= 2.2.5", 98 | "@nestjs/axios": ">= 0.1.0", 99 | "cosmiconfig": ">= 8.0.0", 100 | "dotenv": ">= 16.0.0", 101 | "dotenv-expand": ">= 10.0.0", 102 | "parse-json": ">= 5.2.0", 103 | "yaml": ">= 1.10.2" 104 | }, 105 | "peerDependencies": { 106 | "@nestjs/common": ">= 6.10.0 < 12", 107 | "reflect-metadata": ">= 0.1.12 < 0.3", 108 | "rxjs": ">= 6.0.0 < 8" 109 | }, 110 | "commitlint": { 111 | "extends": [ 112 | "@latipun7/commitlintrc" 113 | ] 114 | }, 115 | "release": { 116 | "extends": [ 117 | "@latipun7/releaserc" 118 | ] 119 | }, 120 | "lint-staged": { 121 | "**/*.ts": [ 122 | "eslint --fix", 123 | "prettier --write" 124 | ], 125 | "**/*": "prettier --write --ignore-unknown" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/e2e/remote.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from '../src/app.module'; 4 | import { TableConfig } from '../src/config.model'; 5 | import { RemoteLoaderConfigType, RemoteLoaderOptions } from '../../lib'; 6 | import axios from 'axios'; 7 | 8 | jest.mock('axios'); 9 | 10 | describe('Remote loader', () => { 11 | let app: INestApplication; 12 | const instance = { 13 | request: jest.fn(), 14 | }; 15 | 16 | beforeEach(async () => { 17 | const create = jest.fn().mockReturnValue(instance); 18 | axios.create = create as any; 19 | axios.CancelToken = { 20 | source: jest.fn().mockReturnValue({ token: 'token', cancel: jest.fn() }), 21 | } as any; 22 | instance.request.mockClear(); 23 | }); 24 | 25 | it(`should be able to parse config format of json, yaml and toml`, async () => { 26 | instance.request.mockResolvedValue({ 27 | data: { 28 | json: `{ 29 | "database": { 30 | "port": 3000, 31 | "host": "0.0.0.0", 32 | "table": { 33 | "name": "json" 34 | } 35 | }, 36 | "isAuthEnabled": true 37 | }`, 38 | yaml: ` 39 | isAuthEnabled: true 40 | database: 41 | host: 127.0.0.1 42 | port: 3000 43 | table: 44 | name: yaml 45 | `, 46 | yml: ` 47 | isAuthEnabled: true 48 | database: 49 | host: 127.0.0.1 50 | port: 3000 51 | table: 52 | name: yml 53 | `, 54 | toml: ` 55 | isAuthEnabled = true 56 | [database] 57 | host = '127.0.0.1' 58 | port = 3000 59 | 60 | [database.table] 61 | name = 'toml' 62 | `, 63 | }, 64 | }); 65 | const getTableConfig = async (option: RemoteLoaderOptions) => { 66 | const module = await Test.createTestingModule({ 67 | imports: [AppModule.withRemoteConfig(option)], 68 | }).compile(); 69 | 70 | app = module.createNestApplication(); 71 | await app.init(); 72 | 73 | const config = app.get(TableConfig); 74 | return config; 75 | }; 76 | 77 | const run = async (type: RemoteLoaderConfigType) => { 78 | const config = await getTableConfig({ 79 | type: type === 'yml' ? type : () => type, 80 | mapResponse: res => res[type], 81 | retries: 1, 82 | retryInterval: 100, 83 | }); 84 | 85 | expect(config.name).toBe(type); 86 | }; 87 | 88 | await run('json'); 89 | await run('yaml'); 90 | await run('yml'); 91 | await run('toml'); 92 | }); 93 | 94 | it(`should be able to load config from remote url`, async () => { 95 | instance.request.mockResolvedValue({ 96 | data: { 97 | database: { 98 | port: 3000, 99 | host: '0.0.0.0', 100 | table: { 101 | name: 'test', 102 | }, 103 | }, 104 | isAuthEnabled: true, 105 | }, 106 | }); 107 | 108 | const module = await Test.createTestingModule({ 109 | imports: [AppModule.withRemoteConfig()], 110 | }).compile(); 111 | 112 | app = module.createNestApplication(); 113 | await app.init(); 114 | 115 | const tableConfig = app.get(TableConfig); 116 | expect(tableConfig.name).toBe('test'); 117 | }); 118 | 119 | it(`should be able to specify retryInterval and retries`, async () => { 120 | const getTableConfig = async (option: RemoteLoaderOptions) => { 121 | instance.request.mockRejectedValueOnce(new Error(`Rejected #1`)); 122 | instance.request.mockResolvedValueOnce({ data: { code: 400 } }); 123 | instance.request.mockResolvedValueOnce({ 124 | data: { 125 | database: { 126 | port: 3000, 127 | host: '0.0.0.0', 128 | table: { 129 | name: 'test', 130 | }, 131 | }, 132 | isAuthEnabled: true, 133 | }, 134 | }); 135 | const module = await Test.createTestingModule({ 136 | imports: [ 137 | AppModule.withRemoteConfig({ 138 | retryInterval: 100, 139 | shouldRetry: (res: any) => res.data.code === 400, 140 | ...option, 141 | }), 142 | ], 143 | }).compile(); 144 | 145 | app = module.createNestApplication(); 146 | await app.init(); 147 | 148 | return app.get(TableConfig); 149 | }; 150 | 151 | const tableConfig = await getTableConfig({ retries: 2 }); 152 | expect(tableConfig.name).toBe('test'); 153 | 154 | await expect(getTableConfig({ retries: 1 })).rejects.toThrow( 155 | 'the number of retries has been exhausted', 156 | ); 157 | }); 158 | 159 | afterEach(async () => { 160 | await app?.close(); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /lib/loader/dotenv-loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parts of this file come from the official config module for Nest.js 3 | * 4 | * @see https://github.com/nestjs/config/blob/master/lib/config.module.ts 5 | */ 6 | 7 | import * as fs from 'fs'; 8 | import { resolve } from 'path'; 9 | import set from 'set-value'; 10 | import { loadPackage } from '../utils/load-package.util'; 11 | import { debug } from '../utils/debug.util'; 12 | 13 | export interface DotenvLoaderOptions { 14 | /** 15 | * If set, use the separator to parse environment variables to objects. 16 | * 17 | * @example 18 | * 19 | * ```bash 20 | * app__port=8080 21 | * db__host=127.0.0.1 22 | * db__port=3000 23 | * ``` 24 | * 25 | * if `separator` is set to `__`, environment variables above will be parsed as: 26 | * 27 | * ```json 28 | * { 29 | * "app": { 30 | * "port": 8080 31 | * }, 32 | * "db": { 33 | * "host": "127.0.0.1", 34 | * "port": 3000 35 | * } 36 | * } 37 | * ``` 38 | */ 39 | separator?: string; 40 | 41 | /** 42 | * If set, this function will transform all environment variable keys prior to parsing. 43 | * 44 | * Be aware: If you transform multiple keys to the same value only one will remain! 45 | * 46 | * @example 47 | * 48 | * .env file: `PORT=8080` and `keyTransformer: key => key.toLowerCase()` results in `{"port": 8080}` 49 | * 50 | * @param key environment variable key 51 | */ 52 | keyTransformer?: (key: string) => string; 53 | 54 | /** 55 | * If "true", environment files (`.env`) will be ignored. 56 | */ 57 | ignoreEnvFile?: boolean; 58 | 59 | /** 60 | * If "true", predefined environment variables will not be validated. 61 | */ 62 | ignoreEnvVars?: boolean; 63 | 64 | /** 65 | * Path to the environment file(s) to be loaded. 66 | */ 67 | envFilePath?: string | string[]; 68 | 69 | /** 70 | * A boolean value indicating the use of expanded variables. 71 | * If .env contains expanded variables, they'll only be parsed if 72 | * this property is set to true. 73 | * 74 | * Internally, dotenv-expand is used to expand variables. 75 | */ 76 | expandVariables?: boolean; 77 | 78 | /** 79 | * If "true", already defined environment variables will be overwritten. 80 | */ 81 | overrideEnvVars?: boolean; 82 | } 83 | 84 | const loadEnvFile = (options: DotenvLoaderOptions): Record => { 85 | const dotenv = loadPackage>( 86 | 'dotenv', 87 | 'dotenvLoader', 88 | () => require('dotenv'), 89 | ); 90 | const envFilePaths = Array.isArray(options.envFilePath) 91 | ? options.envFilePath 92 | : [options.envFilePath || resolve(process.cwd(), '.env')]; 93 | 94 | let config: Record = {}; 95 | for (const envFilePath of envFilePaths) { 96 | if (fs.existsSync(envFilePath)) { 97 | config = Object.assign( 98 | dotenv.parse(fs.readFileSync(envFilePath)), 99 | config, 100 | ); 101 | if (options.expandVariables) { 102 | const dotenvExpand = loadPackage< 103 | Awaited 104 | >('dotenv-expand', "dotenvLoader's ability to expandVariables", () => 105 | require('dotenv-expand'), 106 | ); 107 | config = dotenvExpand.expand({ parsed: config }).parsed!; 108 | } 109 | } 110 | 111 | Object.entries(config).forEach(([key, value]) => { 112 | if ( 113 | options.overrideEnvVars || 114 | !Object.prototype.hasOwnProperty.call(process.env, key) 115 | ) { 116 | process.env[key] = value; 117 | } else { 118 | debug( 119 | `"${key}" is already defined in \`process.env\` and will not be overwritten`, 120 | ); 121 | } 122 | }); 123 | } 124 | return config; 125 | }; 126 | 127 | /** 128 | * Dotenv loader loads configuration with `dotenv`. 129 | * 130 | */ 131 | export const dotenvLoader = (options: DotenvLoaderOptions = {}) => { 132 | return (): Record => { 133 | const { separator, keyTransformer, ignoreEnvFile, ignoreEnvVars } = options; 134 | 135 | let config = ignoreEnvFile ? {} : loadEnvFile(options); 136 | 137 | if (!ignoreEnvVars) { 138 | config = { 139 | ...config, 140 | ...process.env, 141 | }; 142 | } 143 | 144 | if (keyTransformer !== undefined) { 145 | config = Object.entries(config).reduce>( 146 | (acc, [key, value]) => { 147 | acc[keyTransformer(key)] = value; 148 | return acc; 149 | }, 150 | {}, 151 | ); 152 | } 153 | 154 | if (typeof separator === 'string') { 155 | const temp = {}; 156 | Object.entries(config).forEach(([key, value]) => { 157 | set(temp, key.split(separator), value); 158 | }); 159 | config = temp; 160 | } 161 | 162 | return config; 163 | }; 164 | }; 165 | -------------------------------------------------------------------------------- /lib/loader/remote-loader.ts: -------------------------------------------------------------------------------- 1 | import type { HttpService as THttpService } from '@nestjs/axios'; 2 | import type { AxiosRequestConfig, AxiosResponse } from 'axios'; 3 | import { lastValueFrom } from 'rxjs'; 4 | import { delay, map, retryWhen, take } from 'rxjs/operators'; 5 | import { identity } from '../utils/identity.util'; 6 | import { loadPackage } from '../utils/load-package.util'; 7 | 8 | type AxiosRequestConfigWithoutUrl = Omit; 9 | 10 | export type RemoteLoaderConfigType = 'json' | 'yaml' | 'toml' | 'yml'; 11 | 12 | export interface RemoteLoaderOptions extends AxiosRequestConfigWithoutUrl { 13 | /** 14 | * Config file type 15 | */ 16 | type?: ((response: any) => RemoteLoaderConfigType) | RemoteLoaderConfigType; 17 | 18 | /** 19 | * A function that maps http response body to corresponding config object 20 | */ 21 | mapResponse?: (config: any) => Promise | any; 22 | 23 | /** 24 | * A function that determines if the request should be retried 25 | */ 26 | shouldRetry?: (response: AxiosResponse) => boolean; 27 | 28 | /** 29 | * Number of retries to perform, defaults to 3 30 | */ 31 | retries?: number; 32 | 33 | /** 34 | * Interval in milliseconds between each retry 35 | */ 36 | retryInterval?: number; 37 | } 38 | 39 | /** 40 | * Async loader loads configuration at remote endpoint. 41 | * 42 | * @param url Remote location of configuration 43 | * @param options options to configure async loader, support all `axios` options 44 | */ 45 | export const remoteLoader = ( 46 | url: string, 47 | options: RemoteLoaderOptions = {}, 48 | ): (() => Promise) => { 49 | const HttpService = loadPackage>( 50 | '@nestjs/axios', 51 | 'remoteLoader', 52 | () => require('@nestjs/axios'), 53 | ).HttpService; 54 | const axios = loadPackage>( 55 | 'axios', 56 | 'remoteLoader', 57 | () => require('axios'), 58 | ); 59 | 60 | return async (): Promise => { 61 | const { 62 | mapResponse = identity, 63 | type, 64 | shouldRetry = () => false, 65 | retryInterval = 3000, 66 | retries = 3, 67 | } = options; 68 | 69 | const httpService: THttpService = new HttpService(axios.create()); 70 | 71 | const config = await lastValueFrom( 72 | httpService 73 | .request({ 74 | url, 75 | ...options, 76 | }) 77 | .pipe( 78 | map(response => { 79 | if (shouldRetry(response)) { 80 | throw new Error( 81 | `Error when fetching config, response.data: ${JSON.stringify( 82 | response.data, 83 | )}`, 84 | ); 85 | } 86 | return mapResponse(response.data); 87 | }), 88 | retryWhen(errors => { 89 | let retryCount = 0; 90 | return errors.pipe( 91 | delay(retryInterval), 92 | map(error => { 93 | if (retryCount >= retries) { 94 | throw new Error( 95 | `Fetch config with remote-loader failed, as the number of retries has been exhausted. ${error.message}`, 96 | ); 97 | } 98 | retryCount += 1; 99 | return error; 100 | }), 101 | take(retries + 1), 102 | ); 103 | }), 104 | ), 105 | ); 106 | 107 | const parser = { 108 | json: (content: string) => { 109 | const parseJson = loadPackage( 110 | 'parse-json', 111 | "remoteLoader's ability to parse JSON files", 112 | () => require('parse-json'), 113 | ); 114 | return parseJson(content); 115 | }, 116 | yaml: (content: string) => { 117 | const parseYaml = loadPackage>( 118 | 'yaml', 119 | "remoteLoader's ability to parse YAML files", 120 | () => require('yaml'), 121 | ).parse; 122 | return parseYaml(content); 123 | }, 124 | yml: (content: string) => { 125 | const parseYaml = loadPackage>( 126 | 'yaml', 127 | "remoteLoader's ability to parse YML files", 128 | () => require('yaml'), 129 | ).parse; 130 | return parseYaml(content); 131 | }, 132 | toml: (content: string) => { 133 | const parseToml = loadPackage>( 134 | '@iarna/toml', 135 | "remoteLoader's ability to parse TOML files", 136 | () => require('@iarna/toml'), 137 | ).parse; 138 | return parseToml(content); 139 | }, 140 | }; 141 | 142 | const realType = typeof type === 'function' ? type(config) : type; 143 | if (typeof config === 'string' && realType && parser[realType]) { 144 | return parser[realType](config); 145 | } 146 | 147 | return config; 148 | }; 149 | }; 150 | -------------------------------------------------------------------------------- /lib/typed-config.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Provider } from '@nestjs/common'; 2 | import { red, yellow, cyan, blue } from 'chalk'; 3 | import type { ClassConstructor } from 'class-transformer'; 4 | import type { ValidatorOptions, ValidationError } from 'class-validator'; 5 | import merge from 'lodash.merge'; 6 | import { 7 | TypedConfigModuleAsyncOptions, 8 | TypedConfigModuleOptions, 9 | } from './interfaces/typed-config-module-options.interface'; 10 | import { forEachDeep } from './utils/for-each-deep.util'; 11 | import { identity } from './utils/identity.util'; 12 | import { debug } from './utils/debug.util'; 13 | import { validateSync, plainToClass } from './utils/imports.util'; 14 | 15 | @Module({}) 16 | export class TypedConfigModule { 17 | public static forRoot(options: TypedConfigModuleOptions): DynamicModule { 18 | const rawConfig = this.getRawConfig(options.load); 19 | 20 | return this.getDynamicModule(options, rawConfig); 21 | } 22 | 23 | public static async forRootAsync( 24 | options: TypedConfigModuleAsyncOptions, 25 | ): Promise { 26 | const rawConfig = await this.getRawConfigAsync(options.load); 27 | 28 | return this.getDynamicModule(options, rawConfig); 29 | } 30 | 31 | private static getDynamicModule( 32 | options: TypedConfigModuleOptions | TypedConfigModuleAsyncOptions, 33 | rawConfig: Record, 34 | ) { 35 | const { 36 | schema: Config, 37 | normalize = identity, 38 | validationOptions, 39 | isGlobal = true, 40 | validate = this.validateWithClassValidator.bind(this), 41 | } = options; 42 | 43 | if (typeof rawConfig !== 'object') { 44 | throw new Error( 45 | `Configuration should be an object, received: ${rawConfig}. Please check the return value of \`load()\``, 46 | ); 47 | } 48 | const normalized = normalize(rawConfig); 49 | const config = validate(normalized, Config, validationOptions); 50 | const providers = this.getProviders(config, Config); 51 | 52 | return { 53 | global: isGlobal, 54 | module: TypedConfigModule, 55 | providers, 56 | exports: providers, 57 | }; 58 | } 59 | 60 | private static getRawConfig(load: TypedConfigModuleOptions['load']) { 61 | if (Array.isArray(load)) { 62 | const config = {}; 63 | for (const fn of load) { 64 | // we shouldn't silently catch errors here, because app shouldn't start without the proper config 65 | // same way as it doesn't start without the proper database connection 66 | // and the same way as it now fail for the single loader 67 | try { 68 | const conf = fn(config); 69 | merge(config, conf); 70 | } catch (e: any) { 71 | debug( 72 | `Config load failed: ${e}. Details: ${JSON.stringify(e.details)}`, 73 | ); 74 | throw e; 75 | } 76 | } 77 | return config; 78 | } 79 | return load(); 80 | } 81 | 82 | private static async getRawConfigAsync( 83 | load: TypedConfigModuleAsyncOptions['load'], 84 | ) { 85 | if (Array.isArray(load)) { 86 | const config = {}; 87 | for (const fn of load) { 88 | try { 89 | const conf = await fn(config); 90 | merge(config, conf); 91 | } catch (e: any) { 92 | debug( 93 | `Config load failed: ${e}. Details: ${JSON.stringify(e.details)}`, 94 | ); 95 | throw e; 96 | } 97 | } 98 | return config; 99 | } 100 | return load(); 101 | } 102 | 103 | private static getProviders( 104 | config: any, 105 | Config: ClassConstructor, 106 | ): Provider[] { 107 | const providers: Provider[] = [ 108 | { 109 | provide: Config, 110 | useValue: config, 111 | }, 112 | ]; 113 | forEachDeep(config, value => { 114 | if ( 115 | value && 116 | typeof value === 'object' && 117 | !Array.isArray(value) && 118 | value.constructor !== Object 119 | ) { 120 | providers.push({ provide: value.constructor, useValue: value }); 121 | } 122 | }); 123 | 124 | return providers; 125 | } 126 | 127 | private static validateWithClassValidator( 128 | rawConfig: any, 129 | Config: ClassConstructor, 130 | options?: Partial, 131 | ) { 132 | const config = plainToClass(Config, rawConfig, { 133 | exposeDefaultValues: true, 134 | }); 135 | // defaults to strictest validation rules 136 | const schemaErrors = validateSync(config, { 137 | forbidUnknownValues: true, 138 | whitelist: true, 139 | ...options, 140 | }); 141 | if (schemaErrors.length > 0) { 142 | const configErrorMessage = this.getConfigErrorMessage(schemaErrors); 143 | throw new Error(configErrorMessage); 144 | } 145 | return config; 146 | } 147 | 148 | static getConfigErrorMessage(errors: ValidationError[]): string { 149 | const messages = this.formatValidationError(errors) 150 | .map(({ property, value, constraints }) => { 151 | const constraintMessage = Object.entries( 152 | constraints || /* istanbul ignore next */ {}, 153 | ) 154 | .map( 155 | ([key, val]) => 156 | ` - ${key}: ${yellow(val)}, current config is \`${blue( 157 | JSON.stringify(value), 158 | )}\``, 159 | ) 160 | .join(`\n`); 161 | const msg = [ 162 | ` - config ${cyan(property)} does not match the following rules:`, 163 | `${constraintMessage}`, 164 | ].join(`\n`); 165 | return msg; 166 | }) 167 | .filter(Boolean) 168 | .join(`\n`); 169 | const configErrorMessage = red( 170 | `Configuration is not valid:\n${messages}\n`, 171 | ); 172 | return configErrorMessage; 173 | } 174 | 175 | /** 176 | * Transforms validation error object returned by class-validator to more 177 | * readable error messages. 178 | */ 179 | private static formatValidationError(errors: ValidationError[]) { 180 | const result: { 181 | property: string; 182 | constraints: ValidationError['constraints']; 183 | value: ValidationError['value']; 184 | }[] = []; 185 | const helper = ( 186 | { property, constraints, children, value }: ValidationError, 187 | prefix: string, 188 | ) => { 189 | const keyPath = prefix ? `${prefix}.${property}` : property; 190 | if (constraints) { 191 | result.push({ 192 | property: keyPath, 193 | constraints, 194 | value, 195 | }); 196 | } 197 | if (children && children.length) { 198 | children.forEach(child => helper(child, keyPath)); 199 | } 200 | }; 201 | errors.forEach(error => helper(error, ``)); 202 | return result; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /tests/e2e/dotenv.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { join } from 'path'; 4 | import { DotenvLoaderOptions } from '../../lib/loader/dotenv-loader'; 5 | import { AppModule } from '../src/app.module'; 6 | import { Config, DatabaseConfig, TableConfig } from '../src/config.model'; 7 | 8 | jest.mock('dotenv', () => { 9 | if (process.env.MISSING_DOTENV) { 10 | throw new Error('module not found: dotenv'); 11 | } 12 | return jest.requireActual('dotenv'); 13 | }); 14 | jest.mock('dotenv-expand', () => { 15 | if (process.env.MISSING_DOTENV_EXPAND) { 16 | throw new Error('module not found: dotenv-expand'); 17 | } 18 | return jest.requireActual('dotenv-expand'); 19 | }); 20 | jest.mock('cosmiconfig', () => { 21 | throw new Error('module not found: cosmiconfig'); 22 | }); 23 | 24 | describe('Dotenv loader', () => { 25 | let app: INestApplication; 26 | const processExitStub = jest.fn(); 27 | const consoleErrorStub = jest.fn(); 28 | 29 | const init = async (option?: DotenvLoaderOptions) => { 30 | const module = await Test.createTestingModule({ 31 | imports: [AppModule.withDotenv(option)], 32 | }).compile(); 33 | 34 | app = module.createNestApplication(); 35 | await app.init(); 36 | }; 37 | 38 | beforeEach(() => { 39 | process.env = {}; 40 | process.exit = processExitStub as any; 41 | console.error = consoleErrorStub as any; 42 | 43 | jest.clearAllMocks(); 44 | }); 45 | 46 | it(`should throw error when dotenv is not installed`, async () => { 47 | process.env = { 48 | MISSING_DOTENV: '1', 49 | }; 50 | expect(() => AppModule.withDotenvNoOption()).toThrowError(); 51 | expect(processExitStub).toBeCalledTimes(1); 52 | expect(consoleErrorStub).toBeCalledTimes(1); 53 | }); 54 | 55 | it(`should throw error when expandVariables is true but dotenv-expand is not installed`, async () => { 56 | process.env = { 57 | MISSING_DOTENV_EXPAND: '1', 58 | }; 59 | expect(() => 60 | AppModule.withDotenv({ 61 | separator: '__', 62 | envFilePath: join(__dirname, '../src/.expand.env'), 63 | expandVariables: true, 64 | }), 65 | ).toThrowError(); 66 | expect(processExitStub).toBeCalledTimes(1); 67 | expect(consoleErrorStub).toBeCalledTimes(1); 68 | }); 69 | 70 | it(`should be able to load config from environment variables when option is empty`, async () => { 71 | process.env = { 72 | name: 'no-option', 73 | }; 74 | const module = await Test.createTestingModule({ 75 | imports: [AppModule.withDotenvNoOption()], 76 | }).compile(); 77 | 78 | app = module.createNestApplication(); 79 | await app.init(); 80 | 81 | const config = app.get(TableConfig); 82 | expect(config.name).toBe('no-option'); 83 | }); 84 | 85 | it(`should assign environment variables to process.env automatically`, async () => { 86 | process.env = { 87 | name: 'assign-process-env', 88 | }; 89 | await init({ 90 | separator: '__', 91 | envFilePath: join(__dirname, '../src/.env'), 92 | }); 93 | expect(process.env.database__host).toBe('test'); 94 | }); 95 | 96 | it(`should not override environment variables which exists on process.env`, async () => { 97 | process.env = { 98 | name: 'assign-process-env', 99 | database__host: 'existing', 100 | }; 101 | await init({ 102 | separator: '__', 103 | envFilePath: join(__dirname, '../src/.env'), 104 | }); 105 | expect(process.env.database__host).toBe('existing'); 106 | expect(app.get(Config).database.host).toBe('existing'); 107 | }); 108 | 109 | it(`should override environment variables which exists on process.env`, async () => { 110 | process.env = { 111 | name: 'assign-process-env', 112 | database__host: 'existing', 113 | }; 114 | await init({ 115 | separator: '__', 116 | envFilePath: join(__dirname, '../src/.env'), 117 | overrideEnvVars: true, 118 | }); 119 | expect(process.env.database__host).toBe('test'); 120 | expect(app.get(Config).database.host).toBe('test'); 121 | }); 122 | 123 | it(`should be able to load config from environment variables`, async () => { 124 | process.env = { 125 | isAuthEnabled: 'true', 126 | database__host: 'test', 127 | database__port: '3000', 128 | database__table__name: 'test', 129 | }; 130 | await init({ separator: '__', ignoreEnvFile: true }); 131 | const config = app.get(Config); 132 | expect(config.database.host).toBe('test'); 133 | }); 134 | 135 | it(`should be able to load config from .env files and ignore environment variables`, async () => { 136 | process.env = { 137 | database__host: 'should-be-ignored', 138 | }; 139 | await init({ 140 | separator: '__', 141 | envFilePath: join(__dirname, '../src/.env'), 142 | ignoreEnvVars: true, 143 | }); 144 | const databaseConfig = app.get(DatabaseConfig); 145 | expect(databaseConfig.host).toBe('test'); 146 | }); 147 | 148 | it(`should be able to load config from multiple files`, async () => { 149 | await init({ 150 | separator: '__', 151 | envFilePath: [ 152 | join(__dirname, '../src/.part1.env'), 153 | join(__dirname, '../src/.part2.env'), 154 | ], 155 | }); 156 | const databaseConfig = app.get(DatabaseConfig); 157 | expect(databaseConfig.host).toBe('part1'); 158 | }); 159 | 160 | it(`should be able to expand variables`, async () => { 161 | await init({ 162 | separator: '__', 163 | envFilePath: join(__dirname, '../src/.expand.env'), 164 | expandVariables: true, 165 | }); 166 | 167 | const tableConfig = app.get(TableConfig); 168 | expect(tableConfig.name).toBe('expand'); 169 | }); 170 | 171 | it(`should be able to load config from transformed environment variables keys`, async () => { 172 | process.env = { 173 | isAuthEnabled: 'true', 174 | DATABASE__HOST: 'should-be-used', 175 | DATABASE__PORT: '4000', 176 | DATABASE__TABLE__NAME: 'should-be-used', 177 | }; 178 | 179 | const module = await Test.createTestingModule({ 180 | imports: [ 181 | AppModule.withDotenv({ 182 | separator: '__', 183 | keyTransformer: key => 184 | key.replace(/[A-Z0-9]{2,}/g, match => match.toLowerCase()), 185 | ignoreEnvFile: true, 186 | }), 187 | ], 188 | }).compile(); 189 | 190 | app = module.createNestApplication(); 191 | await app.init(); 192 | 193 | const config = app.get(Config); 194 | expect(config.isAuthEnabled).toBe(true); 195 | expect(config.database.host).toBe('should-be-used'); 196 | expect(config.database.port).toBe(4000); 197 | expect(config.database.table.name).toBe('should-be-used'); 198 | }); 199 | 200 | afterEach(async () => { 201 | await app?.close(); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /tests/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | import { join } from 'path'; 3 | import { parse as parseYaml } from 'yaml'; 4 | import { 5 | directoryLoader, 6 | fileLoader, 7 | FileLoaderOptions, 8 | remoteLoader, 9 | RemoteLoaderOptions, 10 | TypedConfigModule, 11 | } from '../../lib'; 12 | import { 13 | dotenvLoader, 14 | DotenvLoaderOptions, 15 | } from '../../lib/loader/dotenv-loader'; 16 | import { 17 | BazConfig, 18 | Config, 19 | ConfigWithDefaultValues, 20 | DirectoryConfig, 21 | FooConfig, 22 | TableConfig, 23 | } from './config.model'; 24 | import { ClassConstructor } from 'class-transformer'; 25 | 26 | const loadYaml = function loadYaml(filepath: string, content: string) { 27 | try { 28 | const result = parseYaml(content); 29 | return result; 30 | } catch (error: any) { 31 | error.message = `YAML Error in ${filepath}:\n${error.message}`; 32 | throw error; 33 | } 34 | }; 35 | 36 | export type TestYamlFile = 37 | | '.env.sub.yaml' 38 | | '.env-advanced.sub.yaml' 39 | | '.env-object-cross-referencing.sub.yaml' 40 | | '.env-advanced-self-referencing-tricky.sub.yaml' 41 | | '.env-advanced-chain-reference-wrong-value.sub.yaml' 42 | | '.env-second-with-hardcoded-host-file.sub.yaml' 43 | | '.env-field-name-the-same-as-env.sub.yaml' 44 | | '.env-circular-between2.sub.yaml' 45 | | '.env-circular-between3.sub.yaml' 46 | | '.env-second-file.sub.yaml' 47 | | '.env-self-reference.sub.yaml' 48 | | '.env-reference-array-of-objects.sub.yaml' 49 | | '.env-reference-object.sub.yaml' 50 | | '.env-reference-array-of-primitives.sub.yaml' 51 | | '.env-advanced-backward-reference.sub.yaml' 52 | | '.env-with-default.sub.yaml' 53 | | '.env-missing.yaml'; 54 | 55 | @Module({}) 56 | export class AppModule { 57 | static withMultipleLoaders( 58 | loaderTypes: ('reject' | 'part1' | 'part2')[], 59 | async = true, 60 | ): DynamicModule { 61 | const loaders = loaderTypes.map(type => { 62 | if (type === 'reject') { 63 | return () => { 64 | throw new Error('Not found'); 65 | }; 66 | } 67 | if (type === 'part1') { 68 | return fileLoader({ 69 | searchFrom: __dirname, 70 | basename: '.env.part1', 71 | }); 72 | } 73 | if (type === 'part2') { 74 | return fileLoader({ 75 | searchFrom: __dirname, 76 | basename: '.env.part2', 77 | }); 78 | } 79 | throw new Error('not valid type'); 80 | }); 81 | return { 82 | module: AppModule, 83 | imports: [ 84 | TypedConfigModule[async ? 'forRootAsync' : 'forRoot']({ 85 | schema: Config, 86 | load: loaders, 87 | }), 88 | ], 89 | }; 90 | } 91 | 92 | static withMultipleSchemas(): DynamicModule { 93 | return { 94 | module: AppModule, 95 | imports: [ 96 | TypedConfigModule.forRoot({ 97 | schema: FooConfig, 98 | load: dotenvLoader({ 99 | envFilePath: join(__dirname, '../src/.env.part1'), 100 | }), 101 | }), 102 | TypedConfigModule.forRoot({ 103 | schema: BazConfig, 104 | load: dotenvLoader({ 105 | envFilePath: join(__dirname, '../src/.env.part2'), 106 | }), 107 | }), 108 | ], 109 | }; 110 | } 111 | 112 | static withToml(): DynamicModule { 113 | return { 114 | module: AppModule, 115 | imports: [ 116 | TypedConfigModule.forRoot({ 117 | schema: Config, 118 | load: fileLoader({ 119 | absolutePath: join(__dirname, '.env.toml'), 120 | }), 121 | }), 122 | ], 123 | }; 124 | } 125 | 126 | static withDirectory(): DynamicModule { 127 | return { 128 | module: AppModule, 129 | imports: [ 130 | TypedConfigModule.forRoot({ 131 | schema: DirectoryConfig, 132 | load: directoryLoader({ 133 | directory: join(__dirname, 'dir'), 134 | }), 135 | }), 136 | ], 137 | }; 138 | } 139 | 140 | static withDirectoryInclude(): DynamicModule { 141 | return { 142 | module: AppModule, 143 | imports: [ 144 | TypedConfigModule.forRoot({ 145 | schema: DirectoryConfig, 146 | load: directoryLoader({ 147 | directory: join(__dirname, 'dir2'), 148 | include: /\.toml$/, 149 | }), 150 | }), 151 | ], 152 | }; 153 | } 154 | 155 | static withRawModule(): DynamicModule { 156 | return TypedConfigModule.forRoot({ 157 | schema: Config, 158 | load: fileLoader({ 159 | absolutePath: join(__dirname, '.env.toml'), 160 | }), 161 | }); 162 | } 163 | 164 | static withSpecialFormat(): DynamicModule { 165 | return { 166 | module: AppModule, 167 | imports: [ 168 | TypedConfigModule.forRoot({ 169 | schema: Config, 170 | load: fileLoader({ 171 | basename: '.config', 172 | loaders: { 173 | '.special': loadYaml, 174 | }, 175 | searchFrom: __dirname, 176 | }), 177 | }), 178 | ], 179 | }; 180 | } 181 | 182 | static withErrorToml(): DynamicModule { 183 | return { 184 | module: AppModule, 185 | imports: [ 186 | TypedConfigModule.forRoot({ 187 | schema: Config, 188 | load: fileLoader({ 189 | absolutePath: join(__dirname, '.env.error.toml'), 190 | }), 191 | }), 192 | ], 193 | }; 194 | } 195 | 196 | static withStringConfig(): DynamicModule { 197 | return { 198 | module: AppModule, 199 | imports: [ 200 | TypedConfigModule.forRootAsync({ 201 | schema: Config, 202 | load: () => 'string' as any, 203 | }), 204 | ], 205 | }; 206 | } 207 | 208 | static withConfigNotFound(): DynamicModule { 209 | return { 210 | module: AppModule, 211 | imports: [ 212 | TypedConfigModule.forRoot({ 213 | schema: Config, 214 | load: fileLoader(), 215 | }), 216 | ], 217 | }; 218 | } 219 | 220 | static withValidationFailed(): DynamicModule { 221 | return { 222 | module: AppModule, 223 | imports: [ 224 | TypedConfigModule.forRoot({ 225 | schema: Config, 226 | load: fileLoader({ 227 | absolutePath: join(__dirname, '.env.invalid.toml'), 228 | }), 229 | }), 230 | ], 231 | }; 232 | } 233 | 234 | static withNodeEnv(): DynamicModule { 235 | return { 236 | module: AppModule, 237 | imports: [ 238 | TypedConfigModule.forRoot({ 239 | schema: Config, 240 | load: fileLoader({ 241 | searchFrom: __dirname, 242 | }), 243 | }), 244 | ], 245 | }; 246 | } 247 | 248 | static withYamlSubstitution( 249 | options: FileLoaderOptions, 250 | schema: ClassConstructor = Config, 251 | fileNames: Array = ['.env.sub.yaml'], 252 | ): DynamicModule { 253 | return { 254 | module: AppModule, 255 | imports: [ 256 | TypedConfigModule.forRoot({ 257 | schema, 258 | load: fileNames.map(f => 259 | fileLoader({ 260 | absolutePath: join(__dirname, f), 261 | ...options, 262 | }), 263 | ), 264 | }), 265 | ], 266 | }; 267 | } 268 | 269 | static withDirectorySubstitution(): DynamicModule { 270 | return { 271 | module: AppModule, 272 | imports: [ 273 | TypedConfigModule.forRoot({ 274 | schema: DirectoryConfig, 275 | load: directoryLoader({ 276 | directory: join(__dirname, 'dir_sub'), 277 | ignoreEnvironmentVariableSubstitution: false, 278 | }), 279 | }), 280 | ], 281 | }; 282 | } 283 | 284 | static withDotenvNoOption(): DynamicModule { 285 | return { 286 | module: AppModule, 287 | imports: [ 288 | TypedConfigModule.forRoot({ 289 | schema: TableConfig, 290 | load: dotenvLoader(), 291 | }), 292 | ], 293 | }; 294 | } 295 | 296 | static withDotenv(option?: DotenvLoaderOptions): DynamicModule { 297 | return { 298 | module: AppModule, 299 | imports: [ 300 | TypedConfigModule.forRoot({ 301 | schema: Config, 302 | load: dotenvLoader(option), 303 | validationOptions: { 304 | forbidNonWhitelisted: false, 305 | }, 306 | normalize(config) { 307 | config.isAuthEnabled = config.isAuthEnabled === 'true'; 308 | config.database.port = parseInt(config.database.port, 10); 309 | return config; 310 | }, 311 | }), 312 | ], 313 | }; 314 | } 315 | 316 | static withRemoteConfig(loaderOptions?: RemoteLoaderOptions): DynamicModule { 317 | return { 318 | module: AppModule, 319 | imports: [ 320 | TypedConfigModule.forRootAsync({ 321 | schema: Config, 322 | load: remoteLoader('http://localhost', loaderOptions), 323 | }), 324 | ], 325 | }; 326 | } 327 | 328 | static withConfigWithDefaultValues(): DynamicModule { 329 | return { 330 | module: AppModule, 331 | imports: [ 332 | TypedConfigModule.forRoot({ 333 | schema: ConfigWithDefaultValues, 334 | load: dotenvLoader(), 335 | normalize(config) { 336 | return { 337 | propertyWithDefaultValue: config.UNDEFINED_ENV_PROPERTY, 338 | }; 339 | }, 340 | }), 341 | ], 342 | }; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We would love for you to contribute and help make it even better than it is 4 | today! As a contributor, here are the guidelines we would like you to follow: 5 | 6 | - [Code of Conduct](#coc) 7 | - [Question or Problem?](#question) 8 | - [Issues and Bugs](#issue) 9 | - [Feature Requests](#feature) 10 | - [Submission Guidelines](#submit) 11 | - [Coding Rules](#rules) 12 | - [Commit Message Guidelines](#commit) 13 | 14 | 26 | 27 | ## Found a Bug? 28 | 29 | If you find a bug in the source code, you can help us by 30 | [submitting an issue](#submit-issue) to our [GitHub Repository][github]. Even better, you can 31 | [submit a Pull Request](#submit-pr) with a fix. 32 | 33 | ## Missing a Feature? 34 | 35 | You can _request_ a new feature by [submitting an issue](#submit-issue) to our GitHub 36 | Repository. If you would like to _implement_ a new feature, please submit an issue with 37 | a proposal for your work first, to be sure that we can use it. 38 | Please consider what kind of change it is: 39 | 40 | - For a **Major Feature**, first open an issue and outline your proposal so that it can be 41 | discussed. This will also allow us to better coordinate our efforts, prevent duplication of work, 42 | and help you to craft the change so that it is successfully accepted into the project. For your issue name, please prefix your proposal with `[discussion]`, for example "[discussion]: your feature idea". 43 | - **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 44 | 45 | ## Submission Guidelines 46 | 47 | ### Submitting an Issue 48 | 49 | Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. 50 | 51 | We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: 52 | 53 | - version of NestJS used 54 | - version of Nest-Typed-Config used 55 | - 3rd-party libraries and their versions 56 | - and most importantly - a use-case that fails 57 | 58 | Unfortunately, we are not able to investigate / fix bugs without a minimal reproduction, so if we don't hear back from you we are going to close an issue that don't have enough info to be reproduced. 59 | 60 | You can file new issues by filling out our [new issue form](https://github.com/Nikaple/nest-typed-config/issues/new). 61 | 62 | ### Submitting a Pull Request (PR) 63 | 64 | Before you submit your Pull Request (PR) consider the following guidelines: 65 | 66 | 68 | 69 | 1. Search [GitHub](https://github.com/Nikaple/nest-typed-config/pulls) for an open or closed PR 70 | that relates to your submission. You don't want to duplicate effort. 71 | 1. Fork the Nikaple/nest-typed-config repo. 72 | 1. Make your changes in a new git branch: 73 | 74 | ```shell 75 | git checkout -b my-fix-branch main 76 | ``` 77 | 78 | 1. Create your patch, **including appropriate test cases**. 79 | 1. Follow our [Coding Rules](#rules). 80 | 1. Run the full test suite, and ensure that all tests pass. 81 | 1. Commit your changes using a descriptive commit message that follows our 82 | [commit message conventions](#commit). Adherence to these conventions 83 | is necessary because release notes are automatically generated from these messages. 84 | 85 | ```shell 86 | git commit -a 87 | ``` 88 | 89 | Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files. 90 | 91 | 1. Push your branch to GitHub: 92 | 93 | ```shell 94 | git push origin my-fix-branch 95 | ``` 96 | 97 | 1. In GitHub, send a pull request to `nest-typed-config:main`. 98 | 99 | 1. If we suggest changes then: 100 | 101 | - Make the required updates. 102 | - Re-run the Nest test suites to ensure tests are still passing. 103 | - Rebase your branch and force push to your GitHub repository (this will update your Pull Request): 104 | 105 | ```shell 106 | git rebase main -i 107 | git push -f 108 | ``` 109 | 110 | That's it! Thank you for your contribution! 111 | 112 | #### After your pull request is merged 113 | 114 | After your pull request is merged, you can safely delete your branch and pull the changes 115 | from the main (upstream) repository: 116 | 117 | - Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: 118 | 119 | ```shell 120 | git push origin --delete my-fix-branch 121 | ``` 122 | 123 | - Check out the main branch: 124 | 125 | ```shell 126 | git checkout main -f 127 | ``` 128 | 129 | - Delete the local branch: 130 | 131 | ```shell 132 | git branch -D my-fix-branch 133 | ``` 134 | 135 | - Update your main with the latest upstream version: 136 | 137 | ```shell 138 | git pull --ff upstream main 139 | ``` 140 | 141 | ## Coding Rules 142 | 143 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 144 | 145 | - All features or bug fixes **must be tested** by one or more specs (unit-tests). 146 | 147 | 150 | 151 | - We follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at 152 | **100 characters**. An automated formatter is available, and can be executed by `npm run lint`. 153 | 154 | ## Commit Message Guidelines 155 | 156 | We have very precise rules over how our git commit messages can be formatted. This leads to **more 157 | readable messages** that are easy to follow when looking through the **project history**. But also, 158 | we use the git commit messages to **generate the changelog**. 159 | 160 | ### Commit Message Format 161 | 162 | Each commit message consists of a **header**, a **body** and a **footer**. The header has a special 163 | format that includes a **type**, a **scope** and a **subject**: 164 | 165 | ``` 166 | (): 167 | 168 | 169 | 170 |