Skip to content

Commit

Permalink
feat: support using config in decorators with selectConfig
Browse files Browse the repository at this point in the history
  • Loading branch information
Nikaple committed May 10, 2021
1 parent ea2c483 commit 09cdb95
Show file tree
Hide file tree
Showing 13 changed files with 209 additions and 41 deletions.
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export class AppService {
}
```

For a full example, please visit our [examples](https://github.com/Nikaple/nest-typed-config/tree/main/examples/basic) folder
For a full example, please visit our [examples](https://github1s.com/Nikaple/nest-typed-config/blob/main/examples/basic/src/app.module.ts) folder.


## Using loaders
Expand Down Expand Up @@ -407,6 +407,75 @@ export class Config {
}
```

## Using config in decorators

> **CAUTION!** You can't use config in decorators through dependency injection, using it will make you struggle harder writing unit tests.
Due to the nature of JavaScript loading modules, decorators are executed before Nest's module initialization. If you want to get config value in decorators like `@Controller()` or `@WebSocketGateway()`, config module should be initialized before application bootstrap.

Suppose we need to inject routing information from the configuration, then we can define the configuration like this:

```ts
// config.ts
import { Type } from 'class-transformer';
import { IsDefined, IsNumber, IsString } from 'class-validator';

export class RouteConfig {
@IsString()
public readonly app!: string;
}

export class RootConfig {
@IsDefined()
@Type(() => RouteConfig)
public readonly route!: RouteConfig;
}
```

Then create a configuration file:

```yaml
route:
app: /app
```
After creating the configuration file, we can initialize our `ConfigModule` with `TypedConfigModule`, and select `RootConfig` from `ConfigModule` using `selectConfig` method.

```ts
// config.module.ts
import { TypedConfigModule, fileLoader, selectConfig } from 'nest-typed-config';
import { RouteConfig } from './config';
export const ConfigModule = TypedConfigModule.forRoot({
schema: RootConfig,
load: fileLoader(),
});
export const rootConfig = selectConfig(ConfigModule, RootConfig);
export const routeConfig = selectConfig(ConfigModule, RouteConfig);
```

That's it! You can use `rootConfig` and `routeConfig` anywhere in your app now!

```ts
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { rootConfig } from './config.module';
@Controller(routeConfig.app)
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
show(): void {
return this.appService.show();
}
}
```

For a full example, please visit our [examples](https://github1s.com/Nikaple/nest-typed-config/blob/main/examples/preload/src/app.module.ts) folder.

## API

### TypedConfigModule.forRoot
Expand Down Expand Up @@ -457,7 +526,7 @@ export interface TypedConfigModuleOptions {
}
```

## changelog
## Changelog

Please refer to [CHANGELOG.md](/~https://github.com/Nikaple/nest-typed-config/blob/main/CHANGELOG.md)

Expand Down
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './typed-config.module';
export * from './interfaces';
export * from './loader';
export * from './utils';
2 changes: 2 additions & 0 deletions lib/interfaces/typed-config-module-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface TypedConfigModuleOptions {
load: ConfigLoader | ConfigLoader[];

/**
* Defaults to "true".
*
* If "true", registers `ConfigModule` as a global module.
* See: https://docs.nestjs.com/modules#global-modules
*/
Expand Down
2 changes: 1 addition & 1 deletion lib/loader/dotenv-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const loadEnvFile = (options: DotenvLoaderOptions): Record<string, any> => {
*
*/
export const dotenvLoader = (options: DotenvLoaderOptions = {}) => {
return async (): Promise<any> => {
return (): Record<string, any> => {
const { ignoreEnvFile, ignoreEnvVars, separator } = options;

let config = ignoreEnvFile ? {} : loadEnvFile(options);
Expand Down
12 changes: 6 additions & 6 deletions lib/loader/file-loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cosmiconfig, Options } from 'cosmiconfig';
import { cosmiconfigSync, OptionsSync } from 'cosmiconfig';
import { parse as parseToml } from '@iarna/toml';
import { Config } from 'cosmiconfig/dist/types';
import { basename, dirname } from 'path';
Expand All @@ -14,7 +14,7 @@ const loadToml = function loadToml(filepath: string, content: string) {
}
};

export interface FileLoaderOptions extends Partial<Options> {
export interface FileLoaderOptions extends Partial<OptionsSync> {
/**
* basename of config file, defaults to `.env`.
*
Expand Down Expand Up @@ -53,7 +53,7 @@ const getSearchOptions = (options: FileLoaderOptions) => {
};

/**
* File loader loads configuration with `cosmiconfig`.
* File loader loads configuration with `cosmiconfig` from file system.
*
* It is designed to be easy to use by default:
* 1. Searching for configuration file starts at `process.cwd()`, and continues
Expand All @@ -67,18 +67,18 @@ const getSearchOptions = (options: FileLoaderOptions) => {
* @param options cosmiconfig initialize options. See: /~https://github.com/davidtheclark/cosmiconfig#cosmiconfigoptions
*/
export const fileLoader = (options: FileLoaderOptions = {}) => {
return async (): Promise<Config> => {
return (): Config => {
const { searchPlaces, searchFrom } = getSearchOptions(options);
const loaders = {
'.toml': loadToml,
...options.loaders,
};
const explorer = cosmiconfig('env', {
const explorer = cosmiconfigSync('env', {
searchPlaces,
...options,
loaders,
});
const result = await explorer.search(searchFrom);
const result = explorer.search(searchFrom);

if (!result) {
throw new Error(`Failed to find configuration file.`);
Expand Down
39 changes: 34 additions & 5 deletions lib/typed-config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,32 @@ import { debug } from './utils/debug.util';

@Module({})
export class TypedConfigModule {
static async forRoot(
static forRoot(options: TypedConfigModuleOptions): DynamicModule {
const rawConfig = this.getRawConfig(options.load);

return this.getDynamicModule(options, rawConfig);
}

static async forRootAsync(
options: TypedConfigModuleOptions,
): Promise<DynamicModule> {
const rawConfig = await this.getRawConfigAsync(options.load);

return this.getDynamicModule(options, rawConfig);
}

private static getDynamicModule(
options: TypedConfigModuleOptions,
rawConfig: Record<string, any>,
) {
const {
load,
schema: Config,
normalize = identity,
validationOptions,
isGlobal,
isGlobal = true,
validate = this.validateWithClassValidator.bind(this),
} = options;

const rawConfig = await this.getRawConfig(load);
if (typeof rawConfig !== 'object') {
throw new Error(
`Configuration should be an object, received: ${rawConfig}. Please check the return value of \`load()\``,
Expand All @@ -47,7 +60,23 @@ export class TypedConfigModule {
};
}

private static async getRawConfig(load: ConfigLoader | ConfigLoader[]) {
private static getRawConfig(load: ConfigLoader | ConfigLoader[]) {
if (Array.isArray(load)) {
const config = {};
for (const fn of load) {
try {
const conf = fn();
merge(config, conf);
} catch (err) {
debug(`Config load failed: ${err.message}`);
}
}
return config;
}
return load();
}

private static async getRawConfigAsync(load: ConfigLoader | ConfigLoader[]) {
if (Array.isArray(load)) {
const config = {};
for (const fn of load) {
Expand Down
1 change: 1 addition & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './select-config.util';
16 changes: 16 additions & 0 deletions lib/utils/select-config.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DynamicModule, ValueProvider } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';

export const selectConfig = <T>(
module: DynamicModule,
Config: ClassConstructor<T>,
): T => {
const providers = module.providers as ValueProvider<T>[];
const selectedConfig = (providers || []).filter(
({ provide }) => provide === Config,
)[0];
if (!selectedConfig) {
throw new Error(`You can only select config which exists in providers`);
}
return selectedConfig.useValue;
};
14 changes: 10 additions & 4 deletions tests/e2e/multiple-loaders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { Config, DatabaseConfig, TableConfig } from '../src/config.model';
describe('Local toml', () => {
let app: INestApplication;

const init = async (option: ('reject' | 'part1' | 'part2')[]) => {
const init = async (
option: ('reject' | 'part1' | 'part2')[],
async = true,
) => {
const module = await Test.createTestingModule({
imports: [AppModule.withMultipleLoaders(option)],
imports: [AppModule.withMultipleLoaders(option, async)],
}).compile();

app = module.createNestApplication();
Expand All @@ -26,17 +29,20 @@ describe('Local toml', () => {
});

it(`should assure that loaders with largest index have highest priority`, async () => {
await init(['part2', 'part1']);
await init(['part2', 'part1'], false);

const databaseConfig = app.get(DatabaseConfig);
expect(databaseConfig.host).toBe('host.part1');
});

it(`should be able load config when some of the loaders fail`, async () => {
await init(['reject', 'part1', 'part2']);

const tableConfig = app.get(TableConfig);
expect(tableConfig.name).toBe('test');

await init(['reject', 'part1', 'part2'], false);
const tableConfig2 = app.get(TableConfig);
expect(tableConfig2.name).toBe('test');
});

afterEach(async () => {
Expand Down
9 changes: 3 additions & 6 deletions tests/e2e/no-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@ describe('No config', () => {
let app: INestApplication;
let module: TestingModuleBuilder;

beforeEach(async () => {
module = Test.createTestingModule({
imports: [AppModule.withConfigNotFound()],
});
});

it(`should not bootstrap when no config file is found`, async () => {
expect.assertions(1);

try {
module = Test.createTestingModule({
imports: [AppModule.withConfigNotFound()],
});
await module.compile();
} catch (err) {
expect(err.message).toMatch(/Failed to find configuration file/);
Expand Down
38 changes: 38 additions & 0 deletions tests/e2e/select-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { selectConfig } from '../../lib';
import { AppModule } from '../src/app.module';
import { Config, DatabaseConfig, TableConfig } from '../src/config.model';

describe('Local toml', () => {
it(`should be able to select config`, async () => {
const module = AppModule.withRawModule();

const config = selectConfig(module, Config);
expect(config.isAuthEnabled).toBe(true);

const databaseConfig = selectConfig(module, DatabaseConfig);
expect(databaseConfig.port).toBe(3000);

const tableConfig = selectConfig(module, TableConfig);
expect(tableConfig.name).toBe('test');
});

it(`can only select existing config`, async () => {
const module = AppModule.withRawModule();

try {
selectConfig(module, class {});
} catch (err) {
expect(err.message).toMatch(
/You can only select config which exists in providers/,
);
}

try {
selectConfig({ module: class {} }, class {});
} catch (err) {
expect(err.message).toMatch(
/You can only select config which exists in providers/,
);
}
});
});
9 changes: 3 additions & 6 deletions tests/e2e/validation-failed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@ describe('Validation failed', () => {
let app: INestApplication;
let module: TestingModuleBuilder;

beforeEach(async () => {
module = Test.createTestingModule({
imports: [AppModule.withValidationFailed()],
});
});

it(`should not bootstrap when validation fails`, async () => {
expect.assertions(3);

try {
module = Test.createTestingModule({
imports: [AppModule.withValidationFailed()],
});
await module.compile();
} catch (err) {
expect(err.message).toMatch(/isAuthEnabled must be a boolean value/);
Expand Down
Loading

0 comments on commit 09cdb95

Please sign in to comment.