@tndhuy/create-app 1.0.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/README.md +53 -0
- package/dist/cli.js +534 -0
- package/package.json +37 -0
- package/templates/mongo/.env.example +32 -0
- package/templates/mongo/Dockerfile +64 -0
- package/templates/mongo/docker-compose.yml +35 -0
- package/templates/mongo/eslint.config.mjs +35 -0
- package/templates/mongo/nest-cli.json +8 -0
- package/templates/mongo/package.json +105 -0
- package/templates/mongo/src/app.module.ts +59 -0
- package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/mongo/src/common/index.ts +5 -0
- package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
- package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
- package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/mongo/src/instrumentation.spec.ts +24 -0
- package/templates/mongo/src/instrumentation.ts +44 -0
- package/templates/mongo/src/main.ts +102 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/mongo/src/modules/example/example.module.ts +31 -0
- package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
- package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
- package/templates/mongo/src/shared/base/domain-event.ts +6 -0
- package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
- package/templates/mongo/src/shared/base/entity.ts +13 -0
- package/templates/mongo/src/shared/base/index.ts +5 -0
- package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
- package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
- package/templates/mongo/src/shared/base/value-object.ts +13 -0
- package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
- package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/mongo/src/shared/index.ts +7 -0
- package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
- package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
- package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
- package/templates/mongo/src/shared/validation-options.ts +38 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/mongo/tsconfig.build.json +4 -0
- package/templates/mongo/tsconfig.json +23 -0
- package/templates/postgres/.env.example +32 -0
- package/templates/postgres/Dockerfile +64 -0
- package/templates/postgres/eslint.config.mjs +35 -0
- package/templates/postgres/nest-cli.json +8 -0
- package/templates/postgres/package.json +103 -0
- package/templates/postgres/prisma/schema.prisma +14 -0
- package/templates/postgres/prisma.config.ts +11 -0
- package/templates/postgres/src/app.module.ts +34 -0
- package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/postgres/src/common/index.ts +5 -0
- package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
- package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
- package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
- package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
- package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/postgres/src/instrumentation.spec.ts +24 -0
- package/templates/postgres/src/instrumentation.ts +44 -0
- package/templates/postgres/src/main.ts +102 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/postgres/src/modules/example/example.module.ts +26 -0
- package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
- package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
- package/templates/postgres/src/shared/base/domain-event.ts +6 -0
- package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
- package/templates/postgres/src/shared/base/entity.ts +13 -0
- package/templates/postgres/src/shared/base/index.ts +5 -0
- package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
- package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
- package/templates/postgres/src/shared/base/value-object.ts +13 -0
- package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
- package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/postgres/src/shared/index.ts +7 -0
- package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
- package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
- package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
- package/templates/postgres/src/shared/validation-options.ts +38 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/postgres/tsconfig.build.json +4 -0
- package/templates/postgres/tsconfig.json +23 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
|
3
|
+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
|
4
|
+
import { PrismaHealthIndicator } from './prisma.health-indicator';
|
|
5
|
+
import { RedisHealthIndicator } from './redis.health-indicator';
|
|
6
|
+
import { RawResponse } from '../../common';
|
|
7
|
+
|
|
8
|
+
@ApiTags('health')
|
|
9
|
+
@Controller('health')
|
|
10
|
+
export class HealthController {
|
|
11
|
+
constructor(
|
|
12
|
+
private health: HealthCheckService,
|
|
13
|
+
private db: PrismaHealthIndicator,
|
|
14
|
+
private redis: RedisHealthIndicator,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
@Get()
|
|
18
|
+
@HealthCheck()
|
|
19
|
+
@RawResponse()
|
|
20
|
+
@ApiOperation({ summary: 'Overall health check' })
|
|
21
|
+
check() {
|
|
22
|
+
return this.health.check([
|
|
23
|
+
() => this.db.isHealthy('database'),
|
|
24
|
+
() => this.redis.isHealthy('redis'),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Get('ready')
|
|
29
|
+
@HealthCheck()
|
|
30
|
+
@RawResponse()
|
|
31
|
+
@ApiOperation({ summary: 'Readiness probe' })
|
|
32
|
+
ready() {
|
|
33
|
+
return this.health.check([
|
|
34
|
+
() => this.db.isHealthy('database'),
|
|
35
|
+
() => this.redis.isHealthy('redis'),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Get('live')
|
|
40
|
+
@HealthCheck()
|
|
41
|
+
@RawResponse()
|
|
42
|
+
@ApiOperation({ summary: 'Liveness probe' })
|
|
43
|
+
live() {
|
|
44
|
+
return this.health.check([]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TerminusModule } from '@nestjs/terminus';
|
|
3
|
+
import { HealthController } from './health.controller';
|
|
4
|
+
import { PrismaHealthIndicator } from './prisma.health-indicator';
|
|
5
|
+
import { RedisHealthIndicator } from './redis.health-indicator';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [TerminusModule],
|
|
9
|
+
controllers: [HealthController],
|
|
10
|
+
providers: [PrismaHealthIndicator, RedisHealthIndicator],
|
|
11
|
+
})
|
|
12
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
|
|
3
|
+
import { PrismaService } from '../database/prisma.service';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class PrismaHealthIndicator extends HealthIndicator {
|
|
7
|
+
constructor(private readonly prisma: PrismaService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
|
12
|
+
try {
|
|
13
|
+
await this.prisma.$queryRaw`SELECT 1`;
|
|
14
|
+
return this.getStatus(key, true);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new HealthCheckError('Database check failed', this.getStatus(key, false));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
|
|
3
|
+
import { RedisService } from '../cache/redis.service';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class RedisHealthIndicator extends HealthIndicator {
|
|
7
|
+
constructor(private readonly redis: RedisService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
|
12
|
+
try {
|
|
13
|
+
const result = await this.redis.getClient().ping();
|
|
14
|
+
const isUp = result === 'PONG';
|
|
15
|
+
return this.getStatus(key, isUp);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new HealthCheckError('Redis check failed', this.getStatus(key, false));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
describe('instrumentation.ts feature flag', () => {
|
|
2
|
+
const originalEnv = process.env;
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
process.env = { ...originalEnv };
|
|
6
|
+
jest.resetModules();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
process.env = originalEnv;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should export null when OTEL_ENABLED is not set', async () => {
|
|
14
|
+
delete process.env.OTEL_ENABLED;
|
|
15
|
+
const mod = await import('./instrumentation');
|
|
16
|
+
expect(mod.default).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should export null when OTEL_ENABLED is "false"', async () => {
|
|
20
|
+
process.env.OTEL_ENABLED = 'false';
|
|
21
|
+
const mod = await import('./instrumentation');
|
|
22
|
+
expect(mod.default).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
2
|
+
|
|
3
|
+
let sdk: NodeSDK | null = null;
|
|
4
|
+
|
|
5
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
6
|
+
try {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
8
|
+
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
10
|
+
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
|
|
17
|
+
|
|
18
|
+
const serviceName = process.env.OTEL_SERVICE_NAME ?? 'nestjs-backend-template';
|
|
19
|
+
const prometheusPort = parseInt(process.env.OTEL_PROMETHEUS_PORT ?? '9464', 10);
|
|
20
|
+
|
|
21
|
+
const prometheusExporter = new PrometheusExporter({ port: prometheusPort });
|
|
22
|
+
|
|
23
|
+
const traceExporter = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
24
|
+
? new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT })
|
|
25
|
+
: new ConsoleSpanExporter();
|
|
26
|
+
|
|
27
|
+
const instrumentations = getNodeAutoInstrumentations({
|
|
28
|
+
'@opentelemetry/instrumentation-dns': { enabled: false },
|
|
29
|
+
'@opentelemetry/instrumentation-fs': { enabled: false },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
sdk = new NodeSDK({
|
|
33
|
+
resource: resourceFromAttributes({ 'service.name': serviceName }),
|
|
34
|
+
metricReader: prometheusExporter,
|
|
35
|
+
traceExporter,
|
|
36
|
+
instrumentations,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('[OTel] Failed to initialize OpenTelemetry SDK:', err);
|
|
40
|
+
sdk = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default sdk;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import otelSdk from './instrumentation';
|
|
2
|
+
import { NestFactory, Reflector } from '@nestjs/core';
|
|
3
|
+
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
4
|
+
import { VersioningType, ValidationPipe } from '@nestjs/common';
|
|
5
|
+
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|
6
|
+
import { Logger } from 'nestjs-pino';
|
|
7
|
+
import { apiReference } from '@scalar/nestjs-api-reference';
|
|
8
|
+
import basicAuth from 'express-basic-auth';
|
|
9
|
+
import { AppModule } from './app.module';
|
|
10
|
+
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|
11
|
+
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
|
12
|
+
import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor';
|
|
13
|
+
import { validationOptions } from './shared/validation-options';
|
|
14
|
+
import { LoggerService } from './shared/logger/logger.service';
|
|
15
|
+
|
|
16
|
+
async function bootstrap() {
|
|
17
|
+
otelSdk?.start();
|
|
18
|
+
|
|
19
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
|
20
|
+
bufferLogs: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.useLogger(app.get(Logger));
|
|
24
|
+
|
|
25
|
+
app.set('trust proxy', 1);
|
|
26
|
+
|
|
27
|
+
const apiVersion = process.env.API_VERSION ?? '1';
|
|
28
|
+
|
|
29
|
+
app.setGlobalPrefix('api', {
|
|
30
|
+
exclude: ['/health', '/health/ready', '/health/live'],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.enableVersioning({ type: VersioningType.URI, defaultVersion: apiVersion });
|
|
34
|
+
|
|
35
|
+
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
|
36
|
+
|
|
37
|
+
const reflector = app.get(Reflector);
|
|
38
|
+
const loggerService = app.get(LoggerService);
|
|
39
|
+
|
|
40
|
+
app.useGlobalFilters(new HttpExceptionFilter(loggerService));
|
|
41
|
+
|
|
42
|
+
app.useGlobalInterceptors(
|
|
43
|
+
new TransformInterceptor(reflector),
|
|
44
|
+
new TimeoutInterceptor(),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Swagger document
|
|
48
|
+
const documentBuilder = new DocumentBuilder()
|
|
49
|
+
.setTitle(process.env.APP_NAME ?? 'NestJS Backend Template')
|
|
50
|
+
.setDescription(process.env.APP_DESCRIPTION ?? 'API Documentation')
|
|
51
|
+
.setVersion(apiVersion)
|
|
52
|
+
.addBearerAuth()
|
|
53
|
+
.build();
|
|
54
|
+
const document = SwaggerModule.createDocument(app, documentBuilder);
|
|
55
|
+
|
|
56
|
+
// Serve raw spec at /docs/json
|
|
57
|
+
SwaggerModule.setup('docs/json', app, document, {
|
|
58
|
+
jsonDocumentUrl: '/docs/json',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Basic auth for /docs
|
|
62
|
+
const docsUser = process.env.DOCS_USER ?? 'admin';
|
|
63
|
+
const docsPass =
|
|
64
|
+
process.env.DOCS_PASS ??
|
|
65
|
+
(process.env.NODE_ENV === 'production' ? undefined : 'admin');
|
|
66
|
+
if (docsPass) {
|
|
67
|
+
app.use(
|
|
68
|
+
'/docs',
|
|
69
|
+
basicAuth({ users: { [docsUser]: docsPass }, challenge: true }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Scalar API reference at /docs
|
|
74
|
+
app.use(
|
|
75
|
+
'/docs',
|
|
76
|
+
apiReference({
|
|
77
|
+
content: document,
|
|
78
|
+
spec: { content: document },
|
|
79
|
+
hiddenClients: ['fetch', 'xhr'],
|
|
80
|
+
configuration: { agent: { disabled: true } },
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
85
|
+
const httpServer = await app.listen(port);
|
|
86
|
+
|
|
87
|
+
process.on('SIGTERM', () => {
|
|
88
|
+
loggerService.log('SIGTERM signal received: closing HTTP server');
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
void otelSdk?.shutdown().catch(() => undefined);
|
|
91
|
+
httpServer.close(() => {
|
|
92
|
+
loggerService.log('HTTP server closed');
|
|
93
|
+
process.exit(0);
|
|
94
|
+
});
|
|
95
|
+
}, 15000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const logger = app.get(Logger);
|
|
99
|
+
logger.log(`Application running on http://localhost:${port}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
void bootstrap();
|
package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CreateItemHandler } from './create-item.handler';
|
|
2
|
+
import { CreateItemCommand } from './create-item.command';
|
|
3
|
+
import { IItemRepository } from '../../domain/item.repository.interface';
|
|
4
|
+
import { Item } from '../../domain/item.entity';
|
|
5
|
+
|
|
6
|
+
describe('CreateItemHandler', () => {
|
|
7
|
+
let handler: CreateItemHandler;
|
|
8
|
+
let mockRepository: jest.Mocked<IItemRepository>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockRepository = {
|
|
12
|
+
findById: jest.fn(),
|
|
13
|
+
findAll: jest.fn(),
|
|
14
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
15
|
+
delete: jest.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
handler = new CreateItemHandler(mockRepository);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should call repository.save with an Item constructed from the command name', async () => {
|
|
22
|
+
const command = new CreateItemCommand('Widget');
|
|
23
|
+
|
|
24
|
+
await handler.execute(command);
|
|
25
|
+
|
|
26
|
+
expect(mockRepository.save).toHaveBeenCalledTimes(1);
|
|
27
|
+
const savedItem: Item = mockRepository.save.mock.calls[0][0];
|
|
28
|
+
expect(savedItem).toBeInstanceOf(Item);
|
|
29
|
+
expect(savedItem.name.value).toBe('Widget');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return a non-empty string id after saving', async () => {
|
|
33
|
+
const command = new CreateItemCommand('Gadget');
|
|
34
|
+
|
|
35
|
+
const result = await handler.execute(command);
|
|
36
|
+
|
|
37
|
+
expect(typeof result).toBe('string');
|
|
38
|
+
expect(result.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should generate a unique id for each execution', async () => {
|
|
42
|
+
const command = new CreateItemCommand('Widget');
|
|
43
|
+
|
|
44
|
+
const id1 = await handler.execute(command);
|
|
45
|
+
const id2 = await handler.execute(command);
|
|
46
|
+
|
|
47
|
+
expect(id1).not.toBe(id2);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
2
|
+
import { Inject } from '@nestjs/common';
|
|
3
|
+
import { CreateItemCommand } from './create-item.command';
|
|
4
|
+
import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
|
|
5
|
+
import { Item } from '../../domain/item.entity';
|
|
6
|
+
import { ItemName } from '../../domain/item-name.value-object';
|
|
7
|
+
|
|
8
|
+
@CommandHandler(CreateItemCommand)
|
|
9
|
+
export class CreateItemHandler implements ICommandHandler<CreateItemCommand> {
|
|
10
|
+
constructor(
|
|
11
|
+
@Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async execute(command: CreateItemCommand): Promise<string> {
|
|
15
|
+
const id = crypto.randomUUID();
|
|
16
|
+
const item = Item.create(id, ItemName.create(command.name));
|
|
17
|
+
await this.itemRepository.save(item);
|
|
18
|
+
return id;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
|
2
|
+
import { Inject } from '@nestjs/common';
|
|
3
|
+
import { DeleteItemCommand } from './delete-item.command';
|
|
4
|
+
import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
|
|
5
|
+
|
|
6
|
+
@CommandHandler(DeleteItemCommand)
|
|
7
|
+
export class DeleteItemHandler implements ICommandHandler<DeleteItemCommand> {
|
|
8
|
+
constructor(
|
|
9
|
+
@Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async execute(command: DeleteItemCommand): Promise<void> {
|
|
13
|
+
await this.itemRepository.delete(command.id);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
|
|
3
|
+
export class ItemResponseDto {
|
|
4
|
+
@ApiProperty({ description: 'Unique item identifier', example: '550e8400-e29b-41d4-a716-446655440000' })
|
|
5
|
+
id: string;
|
|
6
|
+
|
|
7
|
+
@ApiProperty({ description: 'The name of the item', example: 'My Item' })
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { GetItemHandler } from './get-item.handler';
|
|
2
|
+
import { GetItemQuery } from './get-item.query';
|
|
3
|
+
import { IItemRepository } from '../../domain/item.repository.interface';
|
|
4
|
+
import { Item } from '../../domain/item.entity';
|
|
5
|
+
import { ItemName } from '../../domain/item-name.value-object';
|
|
6
|
+
|
|
7
|
+
describe('GetItemHandler', () => {
|
|
8
|
+
let handler: GetItemHandler;
|
|
9
|
+
let mockRepository: jest.Mocked<IItemRepository>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
mockRepository = {
|
|
13
|
+
findById: jest.fn(),
|
|
14
|
+
findAll: jest.fn(),
|
|
15
|
+
save: jest.fn(),
|
|
16
|
+
delete: jest.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
handler = new GetItemHandler(mockRepository);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should delegate to repository.findById with the query id', async () => {
|
|
23
|
+
const item = Item.create('item-1', ItemName.create('Widget'));
|
|
24
|
+
mockRepository.findById.mockResolvedValue(item);
|
|
25
|
+
|
|
26
|
+
const query = new GetItemQuery('item-1');
|
|
27
|
+
await handler.execute(query);
|
|
28
|
+
|
|
29
|
+
expect(mockRepository.findById).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(mockRepository.findById).toHaveBeenCalledWith('item-1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return the item from the repository when it exists', async () => {
|
|
34
|
+
const item = Item.create('item-2', ItemName.create('Gadget'));
|
|
35
|
+
mockRepository.findById.mockResolvedValue(item);
|
|
36
|
+
|
|
37
|
+
const result = await handler.execute(new GetItemQuery('item-2'));
|
|
38
|
+
|
|
39
|
+
expect(result).toBe(item);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return null when the repository returns null', async () => {
|
|
43
|
+
mockRepository.findById.mockResolvedValue(null);
|
|
44
|
+
|
|
45
|
+
const result = await handler.execute(new GetItemQuery('nonexistent'));
|
|
46
|
+
|
|
47
|
+
expect(result).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
|
2
|
+
import { Inject } from '@nestjs/common';
|
|
3
|
+
import { GetItemQuery } from './get-item.query';
|
|
4
|
+
import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
|
|
5
|
+
import { Item } from '../../domain/item.entity';
|
|
6
|
+
|
|
7
|
+
@QueryHandler(GetItemQuery)
|
|
8
|
+
export class GetItemHandler implements IQueryHandler<GetItemQuery> {
|
|
9
|
+
constructor(
|
|
10
|
+
@Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async execute(query: GetItemQuery): Promise<Item | null> {
|
|
14
|
+
return this.itemRepository.findById(query.id);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
|
2
|
+
import { Inject } from '@nestjs/common';
|
|
3
|
+
import { ListItemsQuery } from './list-items.query';
|
|
4
|
+
import { ITEM_REPOSITORY, IItemRepository } from '../../domain/item.repository.interface';
|
|
5
|
+
import { Item } from '../../domain/item.entity';
|
|
6
|
+
|
|
7
|
+
@QueryHandler(ListItemsQuery)
|
|
8
|
+
export class ListItemsHandler implements IQueryHandler<ListItemsQuery> {
|
|
9
|
+
constructor(
|
|
10
|
+
@Inject(ITEM_REPOSITORY) private readonly itemRepository: IItemRepository,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async execute(_query: ListItemsQuery): Promise<Item[]> {
|
|
14
|
+
return this.itemRepository.findAll();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ItemName } from './item-name.value-object';
|
|
2
|
+
|
|
3
|
+
describe('ItemName value object', () => {
|
|
4
|
+
it('should create an ItemName with a valid non-empty string', () => {
|
|
5
|
+
const name = ItemName.create('Widget');
|
|
6
|
+
|
|
7
|
+
expect(name.value).toBe('Widget');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should trim leading and trailing whitespace on creation', () => {
|
|
11
|
+
const name = ItemName.create(' Trimmed ');
|
|
12
|
+
|
|
13
|
+
expect(name.value).toBe('Trimmed');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should throw when given an empty string', () => {
|
|
17
|
+
expect(() => ItemName.create('')).toThrow('ItemName cannot be empty');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should throw when given a whitespace-only string', () => {
|
|
21
|
+
expect(() => ItemName.create(' ')).toThrow('ItemName cannot be empty');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should be equal to another ItemName with the same trimmed value', () => {
|
|
25
|
+
const name1 = ItemName.create('Widget');
|
|
26
|
+
const name2 = ItemName.create('Widget');
|
|
27
|
+
|
|
28
|
+
expect(name1.equals(name2)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should not be equal to an ItemName with a different value', () => {
|
|
32
|
+
const name1 = ItemName.create('Widget');
|
|
33
|
+
const name2 = ItemName.create('Gadget');
|
|
34
|
+
|
|
35
|
+
expect(name1.equals(name2)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should return false when compared with undefined', () => {
|
|
39
|
+
const name = ItemName.create('Widget');
|
|
40
|
+
|
|
41
|
+
expect(name.equals(undefined)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return true when compared with itself', () => {
|
|
45
|
+
const name = ItemName.create('Widget');
|
|
46
|
+
|
|
47
|
+
expect(name.equals(name)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ValueObject } from '../../../shared/base/value-object';
|
|
2
|
+
|
|
3
|
+
interface ItemNameProps {
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class ItemName extends ValueObject<ItemNameProps> {
|
|
8
|
+
static create(value: string): ItemName {
|
|
9
|
+
if (!value || value.trim().length === 0) {
|
|
10
|
+
throw new Error('ItemName cannot be empty');
|
|
11
|
+
}
|
|
12
|
+
return new ItemName({ value: value.trim() });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get value(): string {
|
|
16
|
+
return this.props.value;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Item } from './item.entity';
|
|
2
|
+
import { ItemName } from './item-name.value-object';
|
|
3
|
+
|
|
4
|
+
describe('Item entity', () => {
|
|
5
|
+
it('should create an item via static factory with correct id and name', () => {
|
|
6
|
+
const name = ItemName.create('Widget');
|
|
7
|
+
const item = Item.create('item-1', name);
|
|
8
|
+
|
|
9
|
+
expect(item.id).toBe('item-1');
|
|
10
|
+
expect(item.name).toBe(name);
|
|
11
|
+
expect(item.name.value).toBe('Widget');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should expose the name value object through the name accessor', () => {
|
|
15
|
+
const name = ItemName.create('Gadget');
|
|
16
|
+
const item = Item.create('item-2', name);
|
|
17
|
+
|
|
18
|
+
expect(item.name.value).toBe('Gadget');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should be equal to another item with the same id', () => {
|
|
22
|
+
const name = ItemName.create('Widget');
|
|
23
|
+
const item1 = Item.create('same-id', name);
|
|
24
|
+
const item2 = Item.create('same-id', ItemName.create('DifferentName'));
|
|
25
|
+
|
|
26
|
+
expect(item1.equals(item2)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should not be equal to an item with a different id', () => {
|
|
30
|
+
const name = ItemName.create('Widget');
|
|
31
|
+
const item1 = Item.create('id-a', name);
|
|
32
|
+
const item2 = Item.create('id-b', name);
|
|
33
|
+
|
|
34
|
+
expect(item1.equals(item2)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should be equal to itself (reference equality)', () => {
|
|
38
|
+
const item = Item.create('item-ref', ItemName.create('Test'));
|
|
39
|
+
|
|
40
|
+
expect(item.equals(item)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return false when compared with undefined', () => {
|
|
44
|
+
const item = Item.create('item-undef', ItemName.create('Test'));
|
|
45
|
+
|
|
46
|
+
expect(item.equals(undefined)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AggregateRoot } from '../../../shared/base/aggregate-root';
|
|
2
|
+
import { ItemName } from './item-name.value-object';
|
|
3
|
+
|
|
4
|
+
export class Item extends AggregateRoot<string> {
|
|
5
|
+
private _name: ItemName;
|
|
6
|
+
|
|
7
|
+
private constructor(id: string, name: ItemName) {
|
|
8
|
+
super(id);
|
|
9
|
+
this._name = name;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static create(id: string, name: ItemName): Item {
|
|
13
|
+
return new Item(id, name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get name(): ItemName {
|
|
17
|
+
return this._name;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Item } from './item.entity';
|
|
2
|
+
|
|
3
|
+
export const ITEM_REPOSITORY = Symbol('IItemRepository');
|
|
4
|
+
|
|
5
|
+
export interface IItemRepository {
|
|
6
|
+
findById(id: string): Promise<Item | null>;
|
|
7
|
+
findAll(): Promise<Item[]>;
|
|
8
|
+
save(item: Item): Promise<void>;
|
|
9
|
+
delete(id: string): Promise<void>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { CqrsModule } from '@nestjs/cqrs';
|
|
3
|
+
import { ItemController } from './presenter/item.controller';
|
|
4
|
+
import { CreateItemHandler } from './application/commands/create-item.handler';
|
|
5
|
+
import { DeleteItemHandler } from './application/commands/delete-item.handler';
|
|
6
|
+
import { GetItemHandler } from './application/queries/get-item.handler';
|
|
7
|
+
import { ListItemsHandler } from './application/queries/list-items.handler';
|
|
8
|
+
import { PrismaItemRepository } from './infrastructure/persistence/prisma-item.repository';
|
|
9
|
+
import { ITEM_REPOSITORY } from './domain/item.repository.interface';
|
|
10
|
+
|
|
11
|
+
const CommandHandlers = [CreateItemHandler, DeleteItemHandler];
|
|
12
|
+
const QueryHandlers = [GetItemHandler, ListItemsHandler];
|
|
13
|
+
|
|
14
|
+
@Module({
|
|
15
|
+
imports: [CqrsModule],
|
|
16
|
+
controllers: [ItemController],
|
|
17
|
+
providers: [
|
|
18
|
+
...CommandHandlers,
|
|
19
|
+
...QueryHandlers,
|
|
20
|
+
{
|
|
21
|
+
provide: ITEM_REPOSITORY,
|
|
22
|
+
useClass: PrismaItemRepository,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
export class ExampleModule {}
|
|
File without changes
|