@dismissible/nestjs-api 0.0.2-canary.5daf195.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/.env.yaml +41 -0
- package/jest.config.ts +35 -0
- package/jest.e2e-config.ts +13 -0
- package/nest-cli.json +16 -0
- package/package.json +58 -0
- package/project.json +94 -0
- package/scripts/performance-test.config.json +29 -0
- package/scripts/performance-test.ts +845 -0
- package/src/app-test.factory.ts +39 -0
- package/src/app.module.ts +60 -0
- package/src/app.setup.ts +52 -0
- package/src/bootstrap.ts +29 -0
- package/src/config/app.config.spec.ts +117 -0
- package/src/config/app.config.ts +20 -0
- package/src/config/config.module.spec.ts +94 -0
- package/src/config/config.module.ts +50 -0
- package/src/config/default-app.config.spec.ts +74 -0
- package/src/config/default-app.config.ts +24 -0
- package/src/config/index.ts +2 -0
- package/src/cors/cors.config.spec.ts +162 -0
- package/src/cors/cors.config.ts +37 -0
- package/src/cors/index.ts +1 -0
- package/src/health/health.controller.spec.ts +24 -0
- package/src/health/health.controller.ts +16 -0
- package/src/health/health.module.ts +9 -0
- package/src/health/index.ts +2 -0
- package/src/helmet/helmet.config.spec.ts +197 -0
- package/src/helmet/helmet.config.ts +60 -0
- package/src/helmet/index.ts +1 -0
- package/src/index.ts +5 -0
- package/src/main.ts +3 -0
- package/src/server/index.ts +1 -0
- package/src/server/server.config.spec.ts +65 -0
- package/src/server/server.config.ts +8 -0
- package/src/swagger/index.ts +2 -0
- package/src/swagger/swagger.config.spec.ts +113 -0
- package/src/swagger/swagger.config.ts +12 -0
- package/src/swagger/swagger.factory.spec.ts +125 -0
- package/src/swagger/swagger.factory.ts +24 -0
- package/src/validation/index.ts +1 -0
- package/src/validation/validation.config.ts +47 -0
- package/test/config/.env.yaml +23 -0
- package/test/config-jwt-auth/.env.yaml +26 -0
- package/test/dismiss.e2e-spec.ts +61 -0
- package/test/full-cycle.e2e-spec.ts +55 -0
- package/test/get-or-create.e2e-spec.ts +51 -0
- package/test/jwt-auth.e2e-spec.ts +335 -0
- package/test/restore.e2e-spec.ts +61 -0
- package/tsconfig.app.json +13 -0
- package/tsconfig.e2e.json +12 -0
- package/tsconfig.json +13 -0
- package/tsconfig.spec.json +12 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
|
2
|
+
import { INestApplication } from '@nestjs/common';
|
|
3
|
+
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
4
|
+
import { AppModule, AppModuleOptions } from './app.module';
|
|
5
|
+
import { configureApp } from './app.setup';
|
|
6
|
+
import { PrismaService } from '@dismissible/nestjs-postgres-storage';
|
|
7
|
+
|
|
8
|
+
export type TestAppOptions = {
|
|
9
|
+
moduleOptions?: AppModuleOptions;
|
|
10
|
+
customize?: (builder: TestingModuleBuilder) => TestingModuleBuilder;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function createTestApp(options?: TestAppOptions): Promise<INestApplication> {
|
|
14
|
+
let builder = Test.createTestingModule({
|
|
15
|
+
imports: [AppModule.forRoot(options?.moduleOptions)],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (options?.customize) {
|
|
19
|
+
builder = options.customize(builder);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const moduleFixture = await builder.compile();
|
|
23
|
+
const app = moduleFixture.createNestApplication<NestFastifyApplication>(
|
|
24
|
+
new FastifyAdapter({
|
|
25
|
+
bodyLimit: 10 * 1024, // 10kb
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
await configureApp(app);
|
|
29
|
+
await app.init();
|
|
30
|
+
|
|
31
|
+
await app.getHttpAdapter().getInstance().ready();
|
|
32
|
+
|
|
33
|
+
return app;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function cleanupTestData(app: INestApplication): Promise<void> {
|
|
37
|
+
const prisma = app.get(PrismaService);
|
|
38
|
+
await prisma.dismissibleItem.deleteMany({});
|
|
39
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DynamicModule, Module, ModuleMetadata, Type } from '@nestjs/common';
|
|
2
|
+
import { HealthModule } from './health';
|
|
3
|
+
import { ConfigModule } from './config';
|
|
4
|
+
import { AppConfig } from './config/app.config';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
7
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
|
+
import { DefaultAppConfig } from './config/default-app.config';
|
|
9
|
+
import { PostgresStorageConfig, PostgresStorageModule } from '@dismissible/nestjs-postgres-storage';
|
|
10
|
+
import {
|
|
11
|
+
JwtAuthHookModule,
|
|
12
|
+
JwtAuthHook,
|
|
13
|
+
JwtAuthHookConfig,
|
|
14
|
+
} from '@dismissible/nestjs-jwt-auth-hook';
|
|
15
|
+
|
|
16
|
+
export type AppModuleOptions = {
|
|
17
|
+
configPath?: string;
|
|
18
|
+
schema?: new () => DefaultAppConfig;
|
|
19
|
+
logger?: Type<IDismissibleLogger>;
|
|
20
|
+
imports?: DynamicModule[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
@Module({})
|
|
24
|
+
export class AppModule {
|
|
25
|
+
static forRoot(options?: AppModuleOptions) {
|
|
26
|
+
return {
|
|
27
|
+
module: AppModule,
|
|
28
|
+
...this.getModuleMetadata(options),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static getModuleMetadata(options?: AppModuleOptions): ModuleMetadata {
|
|
33
|
+
return {
|
|
34
|
+
imports: [
|
|
35
|
+
ConfigModule.forRoot({
|
|
36
|
+
path: options?.configPath ?? join(__dirname, '../config'),
|
|
37
|
+
schema: options?.schema ?? AppConfig,
|
|
38
|
+
}),
|
|
39
|
+
HealthModule,
|
|
40
|
+
...(options?.imports ?? []),
|
|
41
|
+
JwtAuthHookModule.forRootAsync({
|
|
42
|
+
useFactory: (config: JwtAuthHookConfig) => config,
|
|
43
|
+
inject: [JwtAuthHookConfig],
|
|
44
|
+
}),
|
|
45
|
+
DismissibleModule.forRoot({
|
|
46
|
+
logger: options?.logger,
|
|
47
|
+
hooks: [JwtAuthHook],
|
|
48
|
+
storage: PostgresStorageModule.forRootAsync({
|
|
49
|
+
useFactory(config: PostgresStorageConfig) {
|
|
50
|
+
return {
|
|
51
|
+
connectionString: config.connectionString,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
inject: [PostgresStorageConfig],
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/app.setup.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
3
|
+
import fastifyHelmet from '@fastify/helmet';
|
|
4
|
+
import { SwaggerConfig, configureAppWithSwagger } from './swagger';
|
|
5
|
+
import { CorsConfig } from './cors';
|
|
6
|
+
import { HelmetConfig } from './helmet';
|
|
7
|
+
import { ValidationConfig } from './validation';
|
|
8
|
+
|
|
9
|
+
export async function configureApp(app: INestApplication): Promise<void> {
|
|
10
|
+
const fastifyApp = app as NestFastifyApplication;
|
|
11
|
+
|
|
12
|
+
const helmetConfig = app.get(HelmetConfig);
|
|
13
|
+
if (helmetConfig.enabled) {
|
|
14
|
+
await fastifyApp.register(fastifyHelmet, {
|
|
15
|
+
contentSecurityPolicy: helmetConfig.contentSecurityPolicy ?? true,
|
|
16
|
+
crossOriginEmbedderPolicy: helmetConfig.crossOriginEmbedderPolicy ?? true,
|
|
17
|
+
hsts: {
|
|
18
|
+
maxAge: helmetConfig.hstsMaxAge ?? 31536000,
|
|
19
|
+
includeSubDomains: helmetConfig.hstsIncludeSubDomains ?? true,
|
|
20
|
+
preload: helmetConfig.hstsPreload ?? false,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const corsConfig = app.get(CorsConfig);
|
|
26
|
+
if (corsConfig.enabled) {
|
|
27
|
+
app.enableCors({
|
|
28
|
+
origin: corsConfig.origins ?? ['http://localhost:3001', 'http://localhost:5173'],
|
|
29
|
+
methods: corsConfig.methods ?? ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
30
|
+
allowedHeaders: corsConfig.allowedHeaders ?? [
|
|
31
|
+
'Content-Type',
|
|
32
|
+
'Authorization',
|
|
33
|
+
'x-request-id',
|
|
34
|
+
],
|
|
35
|
+
credentials: corsConfig.credentials ?? true,
|
|
36
|
+
maxAge: corsConfig.maxAge ?? 86400,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validationConfig = app.get(ValidationConfig);
|
|
41
|
+
app.useGlobalPipes(
|
|
42
|
+
new ValidationPipe({
|
|
43
|
+
whitelist: validationConfig.whitelist ?? true,
|
|
44
|
+
forbidNonWhitelisted: validationConfig.forbidNonWhitelisted ?? true,
|
|
45
|
+
transform: validationConfig.transform ?? true,
|
|
46
|
+
disableErrorMessages: validationConfig.disableErrorMessages ?? true,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const swaggerConfig = app.get(SwaggerConfig);
|
|
51
|
+
configureAppWithSwagger(app, swaggerConfig);
|
|
52
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NestFactory } from '@nestjs/core';
|
|
2
|
+
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
3
|
+
import { AppModule } from './app.module';
|
|
4
|
+
import { ServerConfig } from './server/server.config';
|
|
5
|
+
import { configureApp } from './app.setup';
|
|
6
|
+
import { DefaultAppConfig } from './config/default-app.config';
|
|
7
|
+
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
|
+
import { Type } from '@nestjs/common';
|
|
9
|
+
|
|
10
|
+
export type IBootstrapOptions = {
|
|
11
|
+
configPath?: string;
|
|
12
|
+
schema?: new () => DefaultAppConfig;
|
|
13
|
+
logger?: Type<IDismissibleLogger>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function bootstrap(options?: IBootstrapOptions) {
|
|
17
|
+
const app = await NestFactory.create<NestFastifyApplication>(
|
|
18
|
+
AppModule.forRoot(options),
|
|
19
|
+
new FastifyAdapter({
|
|
20
|
+
bodyLimit: 10 * 1024, // 10kb
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
await configureApp(app);
|
|
24
|
+
|
|
25
|
+
const serverConfig = app.get(ServerConfig);
|
|
26
|
+
const port = serverConfig.port ?? 3000;
|
|
27
|
+
await app.listen(port, '0.0.0.0');
|
|
28
|
+
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { AppConfig } from './app.config';
|
|
5
|
+
import { SwaggerConfig } from '../swagger/swagger.config';
|
|
6
|
+
import { PostgresStorageConfig } from '@dismissible/nestjs-postgres-storage';
|
|
7
|
+
import { JwtAuthHookConfig } from '@dismissible/nestjs-jwt-auth-hook';
|
|
8
|
+
|
|
9
|
+
describe('AppConfig', () => {
|
|
10
|
+
describe('transformation', () => {
|
|
11
|
+
it('should transform all nested configs correctly', () => {
|
|
12
|
+
const config = plainToInstance(AppConfig, {
|
|
13
|
+
server: { port: 3001 },
|
|
14
|
+
cors: { enabled: true },
|
|
15
|
+
helmet: { enabled: true },
|
|
16
|
+
swagger: { enabled: true },
|
|
17
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
18
|
+
jwtAuth: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(config.swagger).toBeInstanceOf(SwaggerConfig);
|
|
25
|
+
expect(config.swagger.enabled).toBe(true);
|
|
26
|
+
expect(config.db).toBeInstanceOf(PostgresStorageConfig);
|
|
27
|
+
expect(config.db.connectionString).toBe('postgresql://localhost:5432/test');
|
|
28
|
+
expect(config.jwtAuth).toBeInstanceOf(JwtAuthHookConfig);
|
|
29
|
+
expect(config.jwtAuth.enabled).toBe(true);
|
|
30
|
+
expect(config.jwtAuth.wellKnownUrl).toBe(
|
|
31
|
+
'https://auth.example.com/.well-known/openid-configuration',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('validation', () => {
|
|
37
|
+
it('should pass validation with all required nested configs', async () => {
|
|
38
|
+
const config = plainToInstance(AppConfig, {
|
|
39
|
+
server: { port: 3001 },
|
|
40
|
+
cors: { enabled: true },
|
|
41
|
+
helmet: { enabled: true },
|
|
42
|
+
swagger: { enabled: true },
|
|
43
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
44
|
+
jwtAuth: {
|
|
45
|
+
enabled: true,
|
|
46
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const errors = await validate(config);
|
|
50
|
+
expect(errors).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should fail validation when swagger config has invalid nested property', async () => {
|
|
54
|
+
const config = plainToInstance(AppConfig, {
|
|
55
|
+
server: { port: 3001 },
|
|
56
|
+
cors: { enabled: true },
|
|
57
|
+
helmet: { enabled: true },
|
|
58
|
+
swagger: { enabled: true, path: 123 },
|
|
59
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
60
|
+
jwtAuth: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const errors = await validate(config);
|
|
66
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
67
|
+
const swaggerError = errors.find((e) => e.property === 'swagger');
|
|
68
|
+
expect(swaggerError).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should fail validation when db config is invalid', async () => {
|
|
72
|
+
const config = plainToInstance(AppConfig, {
|
|
73
|
+
server: { port: 3001 },
|
|
74
|
+
cors: { enabled: true },
|
|
75
|
+
helmet: { enabled: true },
|
|
76
|
+
swagger: { enabled: true },
|
|
77
|
+
db: { connectionString: 123 },
|
|
78
|
+
jwtAuth: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const errors = await validate(config);
|
|
84
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
85
|
+
const dbError = errors.find((e) => e.property === 'db');
|
|
86
|
+
expect(dbError).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should fail validation when jwtAuth config is invalid', async () => {
|
|
90
|
+
const config = plainToInstance(AppConfig, {
|
|
91
|
+
server: { port: 3001 },
|
|
92
|
+
cors: { enabled: true },
|
|
93
|
+
helmet: { enabled: true },
|
|
94
|
+
swagger: { enabled: true },
|
|
95
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
96
|
+
jwtAuth: { enabled: true, wellKnownUrl: 'not-a-valid-url' },
|
|
97
|
+
});
|
|
98
|
+
const errors = await validate(config);
|
|
99
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
100
|
+
const jwtAuthError = errors.find((e) => e.property === 'jwtAuth');
|
|
101
|
+
expect(jwtAuthError).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should fail validation when jwtAuth enabled is false but wellKnownUrl is required', async () => {
|
|
105
|
+
const config = plainToInstance(AppConfig, {
|
|
106
|
+
server: { port: 3001 },
|
|
107
|
+
cors: { enabled: true },
|
|
108
|
+
helmet: { enabled: true },
|
|
109
|
+
swagger: { enabled: true },
|
|
110
|
+
db: { connectionString: 'postgresql://localhost:5432/test' },
|
|
111
|
+
jwtAuth: { enabled: false },
|
|
112
|
+
});
|
|
113
|
+
const errors = await validate(config);
|
|
114
|
+
expect(errors).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ValidateNested } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { SwaggerConfig } from '../swagger';
|
|
4
|
+
import { DefaultAppConfig } from './default-app.config';
|
|
5
|
+
import { PostgresStorageConfig } from '@dismissible/nestjs-postgres-storage';
|
|
6
|
+
import { JwtAuthHookConfig } from '@dismissible/nestjs-jwt-auth-hook';
|
|
7
|
+
|
|
8
|
+
export class AppConfig extends DefaultAppConfig {
|
|
9
|
+
@ValidateNested()
|
|
10
|
+
@Type(() => SwaggerConfig)
|
|
11
|
+
public readonly swagger!: SwaggerConfig;
|
|
12
|
+
|
|
13
|
+
@ValidateNested()
|
|
14
|
+
@Type(() => PostgresStorageConfig)
|
|
15
|
+
public readonly db!: PostgresStorageConfig;
|
|
16
|
+
|
|
17
|
+
@ValidateNested()
|
|
18
|
+
@Type(() => JwtAuthHookConfig)
|
|
19
|
+
public readonly jwtAuth!: JwtAuthHookConfig;
|
|
20
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ConfigModule } from './config.module';
|
|
2
|
+
import { TypedConfigModule } from 'nest-typed-config';
|
|
3
|
+
import { fileLoader } from 'nest-typed-config';
|
|
4
|
+
|
|
5
|
+
jest.mock('nest-typed-config', () => ({
|
|
6
|
+
TypedConfigModule: {
|
|
7
|
+
forRoot: jest.fn(),
|
|
8
|
+
},
|
|
9
|
+
fileLoader: jest.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
jest.mock('path', () => ({
|
|
13
|
+
join: jest.fn((...args) => args.join('/')),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('ConfigModule', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
jest.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('forRoot', () => {
|
|
22
|
+
it('should return a dynamic module with default path', () => {
|
|
23
|
+
const mockSchema = class TestSchema {};
|
|
24
|
+
const mockFileLoader = jest.fn();
|
|
25
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
26
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
27
|
+
|
|
28
|
+
const result = ConfigModule.forRoot({
|
|
29
|
+
schema: mockSchema,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(result.module).toBe(ConfigModule);
|
|
33
|
+
expect(result.exports).toContain(TypedConfigModule);
|
|
34
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
35
|
+
searchFrom: expect.stringContaining('config'),
|
|
36
|
+
ignoreEnvironmentVariableSubstitution: false,
|
|
37
|
+
});
|
|
38
|
+
expect(TypedConfigModule.forRoot).toHaveBeenCalledWith({
|
|
39
|
+
schema: mockSchema,
|
|
40
|
+
load: [mockFileLoader],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should use custom path when provided', () => {
|
|
45
|
+
const mockSchema = class TestSchema {};
|
|
46
|
+
const customPath = '/custom/config/path';
|
|
47
|
+
const mockFileLoader = jest.fn();
|
|
48
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
49
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
50
|
+
|
|
51
|
+
const result = ConfigModule.forRoot({
|
|
52
|
+
schema: mockSchema,
|
|
53
|
+
path: customPath,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.module).toBe(ConfigModule);
|
|
57
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
58
|
+
searchFrom: customPath,
|
|
59
|
+
ignoreEnvironmentVariableSubstitution: false,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should respect ignoreEnvironmentVariableSubstitution option', () => {
|
|
64
|
+
const mockSchema = class TestSchema {};
|
|
65
|
+
const mockFileLoader = jest.fn();
|
|
66
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
67
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue({});
|
|
68
|
+
|
|
69
|
+
ConfigModule.forRoot({
|
|
70
|
+
schema: mockSchema,
|
|
71
|
+
ignoreEnvironmentVariableSubstitution: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(fileLoader).toHaveBeenCalledWith({
|
|
75
|
+
searchFrom: expect.any(String),
|
|
76
|
+
ignoreEnvironmentVariableSubstitution: true,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should include TypedConfigModule in imports', () => {
|
|
81
|
+
const mockSchema = class TestSchema {};
|
|
82
|
+
const mockFileLoader = jest.fn();
|
|
83
|
+
(fileLoader as jest.Mock).mockReturnValue(mockFileLoader);
|
|
84
|
+
const mockTypedConfigModule = {};
|
|
85
|
+
(TypedConfigModule.forRoot as jest.Mock).mockReturnValue(mockTypedConfigModule);
|
|
86
|
+
|
|
87
|
+
const result = ConfigModule.forRoot({
|
|
88
|
+
schema: mockSchema,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.imports).toContain(mockTypedConfigModule);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { DynamicModule, Global, Module } from '@nestjs/common';
|
|
2
|
+
import { fileLoader, TypedConfigModule } from 'nest-typed-config';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export interface IConfigModuleOptions<T> {
|
|
6
|
+
/**
|
|
7
|
+
* The configuration schema class to validate against
|
|
8
|
+
*/
|
|
9
|
+
schema: new () => T;
|
|
10
|
+
/**
|
|
11
|
+
* The path to search for configuration files
|
|
12
|
+
* Defaults to a 'config' directory two levels up from the current file
|
|
13
|
+
*/
|
|
14
|
+
path?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether to ignore environment variable substitution in config files
|
|
17
|
+
* Defaults to false (environment variables will be substituted)
|
|
18
|
+
*/
|
|
19
|
+
ignoreEnvironmentVariableSubstitution?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Global()
|
|
23
|
+
@Module({})
|
|
24
|
+
export class ConfigModule {
|
|
25
|
+
/**
|
|
26
|
+
* Register the ConfigModule with a configuration schema
|
|
27
|
+
* @param options Configuration options including the schema class
|
|
28
|
+
* @returns A dynamic module configured with TypedConfigModule
|
|
29
|
+
*/
|
|
30
|
+
static forRoot<T extends object>(options: IConfigModuleOptions<T>): DynamicModule {
|
|
31
|
+
const configPath = options.path ?? join(__dirname, '../../config');
|
|
32
|
+
const ignoreEnvSubstitution = options.ignoreEnvironmentVariableSubstitution ?? false;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
module: ConfigModule,
|
|
36
|
+
imports: [
|
|
37
|
+
TypedConfigModule.forRoot({
|
|
38
|
+
schema: options.schema,
|
|
39
|
+
load: [
|
|
40
|
+
fileLoader({
|
|
41
|
+
searchFrom: configPath,
|
|
42
|
+
ignoreEnvironmentVariableSubstitution: ignoreEnvSubstitution,
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
exports: [TypedConfigModule],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { DefaultAppConfig } from './default-app.config';
|
|
5
|
+
import { ServerConfig } from '../server/server.config';
|
|
6
|
+
import { CorsConfig } from '../cors/cors.config';
|
|
7
|
+
import { HelmetConfig } from '../helmet/helmet.config';
|
|
8
|
+
|
|
9
|
+
describe('DefaultAppConfig', () => {
|
|
10
|
+
describe('transformation', () => {
|
|
11
|
+
it('should transform all nested configs correctly', () => {
|
|
12
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
13
|
+
server: { port: 3001 },
|
|
14
|
+
cors: { enabled: true },
|
|
15
|
+
helmet: { enabled: true },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(config.server).toBeInstanceOf(ServerConfig);
|
|
19
|
+
expect(config.server.port).toBe(3001);
|
|
20
|
+
expect(config.cors).toBeInstanceOf(CorsConfig);
|
|
21
|
+
expect(config.cors.enabled).toBe(true);
|
|
22
|
+
expect(config.helmet).toBeInstanceOf(HelmetConfig);
|
|
23
|
+
expect(config.helmet.enabled).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('validation', () => {
|
|
28
|
+
it('should pass validation with all required nested configs', async () => {
|
|
29
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
30
|
+
server: { port: 3001 },
|
|
31
|
+
cors: { enabled: true },
|
|
32
|
+
helmet: { enabled: true },
|
|
33
|
+
});
|
|
34
|
+
const errors = await validate(config);
|
|
35
|
+
expect(errors).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should fail validation when server config is invalid', async () => {
|
|
39
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
40
|
+
server: { port: 'not-a-number' },
|
|
41
|
+
cors: { enabled: true },
|
|
42
|
+
helmet: { enabled: true },
|
|
43
|
+
});
|
|
44
|
+
const errors = await validate(config);
|
|
45
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
46
|
+
const serverError = errors.find((e) => e.property === 'server');
|
|
47
|
+
expect(serverError).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should fail validation when cors config has invalid nested property', async () => {
|
|
51
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
52
|
+
server: { port: 3001 },
|
|
53
|
+
cors: { enabled: true, maxAge: 'not-a-number' },
|
|
54
|
+
helmet: { enabled: true },
|
|
55
|
+
});
|
|
56
|
+
const errors = await validate(config);
|
|
57
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
58
|
+
const corsError = errors.find((e) => e.property === 'cors');
|
|
59
|
+
expect(corsError).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should fail validation when helmet config has invalid nested property', async () => {
|
|
63
|
+
const config = plainToInstance(DefaultAppConfig, {
|
|
64
|
+
server: { port: 3001 },
|
|
65
|
+
cors: { enabled: true },
|
|
66
|
+
helmet: { enabled: true, hstsMaxAge: 'not-a-number' },
|
|
67
|
+
});
|
|
68
|
+
const errors = await validate(config);
|
|
69
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
70
|
+
const helmetError = errors.find((e) => e.property === 'helmet');
|
|
71
|
+
expect(helmetError).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ValidateNested } from 'class-validator';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { ServerConfig } from '../server/server.config';
|
|
4
|
+
import { CorsConfig } from '../cors';
|
|
5
|
+
import { HelmetConfig } from '../helmet';
|
|
6
|
+
import { ValidationConfig } from '../validation';
|
|
7
|
+
|
|
8
|
+
export class DefaultAppConfig {
|
|
9
|
+
@ValidateNested()
|
|
10
|
+
@Type(() => ServerConfig)
|
|
11
|
+
public readonly server!: ServerConfig;
|
|
12
|
+
|
|
13
|
+
@ValidateNested()
|
|
14
|
+
@Type(() => CorsConfig)
|
|
15
|
+
public readonly cors!: CorsConfig;
|
|
16
|
+
|
|
17
|
+
@ValidateNested()
|
|
18
|
+
@Type(() => HelmetConfig)
|
|
19
|
+
public readonly helmet!: HelmetConfig;
|
|
20
|
+
|
|
21
|
+
@ValidateNested()
|
|
22
|
+
@Type(() => ValidationConfig)
|
|
23
|
+
public readonly validation!: ValidationConfig;
|
|
24
|
+
}
|