@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,64 @@
|
|
|
1
|
+
# =========================
|
|
2
|
+
# 1) Dependencies
|
|
3
|
+
# =========================
|
|
4
|
+
FROM node:22-alpine AS deps
|
|
5
|
+
|
|
6
|
+
RUN apk upgrade --no-cache \
|
|
7
|
+
&& addgroup -g 1001 -S appgroup \
|
|
8
|
+
&& adduser -S appuser -u 1001 -G appgroup
|
|
9
|
+
|
|
10
|
+
WORKDIR /app
|
|
11
|
+
RUN chown appuser:appgroup /app
|
|
12
|
+
|
|
13
|
+
COPY --chown=appuser:appgroup package*.json ./
|
|
14
|
+
COPY --chown=appuser:appgroup prisma ./prisma/
|
|
15
|
+
COPY --chown=appuser:appgroup prisma.config.ts ./
|
|
16
|
+
|
|
17
|
+
USER appuser
|
|
18
|
+
|
|
19
|
+
RUN npm ci --fetch-timeout=600000 --fetch-retries=5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# =========================
|
|
23
|
+
# 2) Builder
|
|
24
|
+
# =========================
|
|
25
|
+
FROM deps AS builder
|
|
26
|
+
|
|
27
|
+
COPY --chown=appuser:appgroup . .
|
|
28
|
+
|
|
29
|
+
RUN npx prisma generate && npm run build
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =========================
|
|
33
|
+
# 3) Runner (Production)
|
|
34
|
+
# =========================
|
|
35
|
+
FROM node:22-alpine AS runner
|
|
36
|
+
|
|
37
|
+
RUN apk upgrade --no-cache \
|
|
38
|
+
&& apk add --no-cache curl \
|
|
39
|
+
&& addgroup -g 1001 -S appgroup \
|
|
40
|
+
&& adduser -S appuser -u 1001 -G appgroup
|
|
41
|
+
|
|
42
|
+
WORKDIR /app
|
|
43
|
+
|
|
44
|
+
COPY --from=builder --chown=appuser:appgroup /app/package*.json ./
|
|
45
|
+
COPY --from=builder --chown=appuser:appgroup /app/prisma ./prisma
|
|
46
|
+
COPY --from=builder --chown=appuser:appgroup /app/prisma.config.ts ./prisma.config.ts
|
|
47
|
+
|
|
48
|
+
RUN npm ci --omit=dev --fetch-timeout=600000 --fetch-retries=5 && npm cache clean --force
|
|
49
|
+
|
|
50
|
+
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
|
|
51
|
+
|
|
52
|
+
RUN mkdir -p logs && chown appuser:appgroup logs
|
|
53
|
+
|
|
54
|
+
ENV NODE_ENV=production \
|
|
55
|
+
PORT=3000
|
|
56
|
+
|
|
57
|
+
USER appuser
|
|
58
|
+
|
|
59
|
+
EXPOSE 3000
|
|
60
|
+
|
|
61
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
62
|
+
CMD curl -f http://localhost:3000/health || exit 1
|
|
63
|
+
|
|
64
|
+
CMD ["node", "dist/src/main"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
services:
|
|
2
|
+
mongodb:
|
|
3
|
+
image: mongo:latest
|
|
4
|
+
container_name: nestjs-template-mongodb
|
|
5
|
+
restart: unless-stopped
|
|
6
|
+
ports:
|
|
7
|
+
- "27017:27017"
|
|
8
|
+
environment:
|
|
9
|
+
MONGO_INITDB_DATABASE: template_db
|
|
10
|
+
volumes:
|
|
11
|
+
- mongodb_data:/data/db
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
|
14
|
+
interval: 10s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 10
|
|
17
|
+
|
|
18
|
+
redis:
|
|
19
|
+
image: redis:latest
|
|
20
|
+
container_name: nestjs-template-redis
|
|
21
|
+
restart: unless-stopped
|
|
22
|
+
ports:
|
|
23
|
+
- "6379:6379"
|
|
24
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
25
|
+
volumes:
|
|
26
|
+
- redis_data:/data
|
|
27
|
+
healthcheck:
|
|
28
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
29
|
+
interval: 10s
|
|
30
|
+
timeout: 5s
|
|
31
|
+
retries: 10
|
|
32
|
+
|
|
33
|
+
volumes:
|
|
34
|
+
mongodb_data:
|
|
35
|
+
redis_data:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import eslint from '@eslint/js';
|
|
3
|
+
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
|
4
|
+
import globals from 'globals';
|
|
5
|
+
import tseslint from 'typescript-eslint';
|
|
6
|
+
|
|
7
|
+
export default tseslint.config(
|
|
8
|
+
{
|
|
9
|
+
ignores: ['eslint.config.mjs'],
|
|
10
|
+
},
|
|
11
|
+
eslint.configs.recommended,
|
|
12
|
+
...tseslint.configs.recommendedTypeChecked,
|
|
13
|
+
eslintPluginPrettierRecommended,
|
|
14
|
+
{
|
|
15
|
+
languageOptions: {
|
|
16
|
+
globals: {
|
|
17
|
+
...globals.node,
|
|
18
|
+
...globals.jest,
|
|
19
|
+
},
|
|
20
|
+
sourceType: 'commonjs',
|
|
21
|
+
parserOptions: {
|
|
22
|
+
projectService: true,
|
|
23
|
+
tsconfigRootDir: import.meta.dirname,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
rules: {
|
|
29
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
30
|
+
'@typescript-eslint/no-floating-promises': 'warn',
|
|
31
|
+
'@typescript-eslint/no-unsafe-argument': 'warn',
|
|
32
|
+
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nestjs-backend-template",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"author": "",
|
|
6
|
+
"private": true,
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "nest build",
|
|
10
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
11
|
+
"start": "nest start",
|
|
12
|
+
"start:dev": "nest start --watch",
|
|
13
|
+
"start:debug": "nest start --debug --watch",
|
|
14
|
+
"start:prod": "node dist/src/main",
|
|
15
|
+
"db:generate": "prisma generate",
|
|
16
|
+
"db:sync": "prisma db push",
|
|
17
|
+
"db:sync:force": "prisma db push --accept-data-loss",
|
|
18
|
+
"db:push": "prisma db push",
|
|
19
|
+
"db:pull": "prisma db pull",
|
|
20
|
+
"db:validate": "prisma validate",
|
|
21
|
+
"db:format": "prisma format",
|
|
22
|
+
"db:studio": "prisma studio",
|
|
23
|
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
24
|
+
"test": "jest",
|
|
25
|
+
"test:watch": "jest --watch",
|
|
26
|
+
"test:cov": "jest --coverage",
|
|
27
|
+
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
|
28
|
+
"test:e2e": "jest --config ./test/jest-e2e.json"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@nestjs/common": "^11.0.1",
|
|
32
|
+
"@nestjs/config": "^4.0.3",
|
|
33
|
+
"@nestjs/core": "^11.0.1",
|
|
34
|
+
"@nestjs/cqrs": "^11.0.3",
|
|
35
|
+
"@nestjs/mongoose": "^11.0.4",
|
|
36
|
+
"@nestjs/platform-express": "^11.0.1",
|
|
37
|
+
"@nestjs/swagger": "^11.2.6",
|
|
38
|
+
"@nestjs/terminus": "^11.1.1",
|
|
39
|
+
"@nestjs/throttler": "^6.5.0",
|
|
40
|
+
"@opentelemetry/api": "^1.9.1",
|
|
41
|
+
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
|
|
42
|
+
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
|
43
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
|
44
|
+
"@opentelemetry/resources": "^2.6.1",
|
|
45
|
+
"@opentelemetry/sdk-node": "^0.214.0",
|
|
46
|
+
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
|
47
|
+
"@prisma/client": "^7.5.0",
|
|
48
|
+
"@scalar/nestjs-api-reference": "^1.1.5",
|
|
49
|
+
"class-transformer": "^0.5.1",
|
|
50
|
+
"class-validator": "^0.15.1",
|
|
51
|
+
"cockatiel": "^3.2.1",
|
|
52
|
+
"express-basic-auth": "^1.2.1",
|
|
53
|
+
"ioredis": "^5.10.1",
|
|
54
|
+
"mongoose": "^9.4.1",
|
|
55
|
+
"nestjs-pino": "^4.6.1",
|
|
56
|
+
"pino-http": "^11.0.0",
|
|
57
|
+
"pino-pretty": "^13.1.3",
|
|
58
|
+
"reflect-metadata": "^0.2.2",
|
|
59
|
+
"rxjs": "^7.8.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@eslint/eslintrc": "^3.2.0",
|
|
63
|
+
"@eslint/js": "^9.18.0",
|
|
64
|
+
"@nestjs/cli": "^11.0.0",
|
|
65
|
+
"@nestjs/schematics": "^11.0.0",
|
|
66
|
+
"@nestjs/testing": "^11.0.1",
|
|
67
|
+
"@types/express": "^5.0.0",
|
|
68
|
+
"@types/ioredis": "^5.0.0",
|
|
69
|
+
"@types/jest": "^30.0.0",
|
|
70
|
+
"@types/node": "^22.10.7",
|
|
71
|
+
"@types/supertest": "^6.0.2",
|
|
72
|
+
"eslint": "^9.18.0",
|
|
73
|
+
"eslint-config-prettier": "^10.0.1",
|
|
74
|
+
"eslint-plugin-prettier": "^5.2.2",
|
|
75
|
+
"globals": "^16.0.0",
|
|
76
|
+
"jest": "^30.0.0",
|
|
77
|
+
"prettier": "^3.4.2",
|
|
78
|
+
"prisma": "^7.5.0",
|
|
79
|
+
"source-map-support": "^0.5.21",
|
|
80
|
+
"supertest": "^7.0.0",
|
|
81
|
+
"ts-jest": "^29.2.5",
|
|
82
|
+
"ts-loader": "^9.5.2",
|
|
83
|
+
"ts-node": "^10.9.2",
|
|
84
|
+
"tsconfig-paths": "^4.2.0",
|
|
85
|
+
"typescript": "^5.7.3",
|
|
86
|
+
"typescript-eslint": "^8.20.0"
|
|
87
|
+
},
|
|
88
|
+
"jest": {
|
|
89
|
+
"moduleFileExtensions": [
|
|
90
|
+
"js",
|
|
91
|
+
"json",
|
|
92
|
+
"ts"
|
|
93
|
+
],
|
|
94
|
+
"rootDir": "src",
|
|
95
|
+
"testRegex": ".*\\.spec\\.ts$",
|
|
96
|
+
"transform": {
|
|
97
|
+
"^.+\\.(t|j)s$": "ts-jest"
|
|
98
|
+
},
|
|
99
|
+
"collectCoverageFrom": [
|
|
100
|
+
"**/*.(t|j)s"
|
|
101
|
+
],
|
|
102
|
+
"coverageDirectory": "../coverage",
|
|
103
|
+
"testEnvironment": "node"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
|
2
|
+
import { APP_GUARD } from '@nestjs/core';
|
|
3
|
+
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
|
4
|
+
import { LoggerModule } from 'nestjs-pino';
|
|
5
|
+
import { AppConfigModule } from './infrastructure/config/config.module';
|
|
6
|
+
import { MongoDBModule } from './infrastructure/database/mongodb.module';
|
|
7
|
+
import { CacheModule } from './infrastructure/cache/redis.module';
|
|
8
|
+
import { HealthModule } from './infrastructure/health/health.module';
|
|
9
|
+
import { ExampleModule } from './modules/example/example.module';
|
|
10
|
+
import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware';
|
|
11
|
+
|
|
12
|
+
@Module({
|
|
13
|
+
imports: [
|
|
14
|
+
AppConfigModule,
|
|
15
|
+
MongoDBModule,
|
|
16
|
+
CacheModule,
|
|
17
|
+
HealthModule,
|
|
18
|
+
ExampleModule,
|
|
19
|
+
LoggerModule.forRoot({
|
|
20
|
+
pinoHttp: {
|
|
21
|
+
level: process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
|
|
22
|
+
genReqId: (req) => req.headers['x-correlation-id'] as string,
|
|
23
|
+
transport:
|
|
24
|
+
process.env.NODE_ENV !== 'production'
|
|
25
|
+
? {
|
|
26
|
+
target: 'pino-pretty',
|
|
27
|
+
options: {
|
|
28
|
+
colorize: true,
|
|
29
|
+
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss',
|
|
30
|
+
ignore: 'pid,hostname',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
: undefined,
|
|
34
|
+
serializers: {
|
|
35
|
+
req: (req: { method: string; url: string }) => ({
|
|
36
|
+
method: req.method,
|
|
37
|
+
url: req.url,
|
|
38
|
+
}),
|
|
39
|
+
res: (res: { statusCode: number }) => ({
|
|
40
|
+
statusCode: res.statusCode,
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
ThrottlerModule.forRoot([
|
|
46
|
+
{
|
|
47
|
+
ttl: parseInt(process.env.THROTTLE_TTL ?? '60', 10) * 1000,
|
|
48
|
+
limit: parseInt(process.env.THROTTLE_LIMIT ?? '100', 10),
|
|
49
|
+
},
|
|
50
|
+
]),
|
|
51
|
+
],
|
|
52
|
+
controllers: [],
|
|
53
|
+
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
|
|
54
|
+
})
|
|
55
|
+
export class AppModule implements NestModule {
|
|
56
|
+
configure(consumer: MiddlewareConsumer) {
|
|
57
|
+
consumer.apply(CorrelationIdMiddleware).forRoutes('*');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const PUBLIC_API_KEY = 'public_api';
|
|
4
|
+
/**
|
|
5
|
+
* Decorator to mark an endpoint as Public API.
|
|
6
|
+
* This will trigger the TransformInterceptor to wrap the response in { success: true, data: T }.
|
|
7
|
+
* Without this decorator, the response remains raw.
|
|
8
|
+
*/
|
|
9
|
+
export const PublicApi = () => SetMetadata(PUBLIC_API_KEY, true);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { HttpException } from '@nestjs/common';
|
|
2
|
+
import { AppException } from '../../shared/exceptions/app.exception';
|
|
3
|
+
import { ErrorCodes } from '../../shared/exceptions/error-codes';
|
|
4
|
+
import { HttpExceptionFilter } from './http-exception.filter';
|
|
5
|
+
|
|
6
|
+
function createMockHost(jsonMock: jest.Mock) {
|
|
7
|
+
const statusMock = jest.fn().mockReturnValue({ json: jsonMock });
|
|
8
|
+
const response = { status: statusMock };
|
|
9
|
+
const request = { method: 'GET', url: '/test' };
|
|
10
|
+
return {
|
|
11
|
+
switchToHttp: () => ({
|
|
12
|
+
getResponse: () => response,
|
|
13
|
+
getRequest: () => request,
|
|
14
|
+
}),
|
|
15
|
+
} as any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('HttpExceptionFilter', () => {
|
|
19
|
+
let filter: HttpExceptionFilter;
|
|
20
|
+
let jsonMock: jest.Mock;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
filter = new HttpExceptionFilter();
|
|
24
|
+
jsonMock = jest.fn();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should format AppException(NOT_FOUND) to correct error shape', () => {
|
|
28
|
+
const exception = new AppException({
|
|
29
|
+
code: ErrorCodes.NOT_FOUND,
|
|
30
|
+
message: 'Item not found',
|
|
31
|
+
statusCode: 404,
|
|
32
|
+
});
|
|
33
|
+
const host = createMockHost(jsonMock);
|
|
34
|
+
|
|
35
|
+
filter.catch(exception, host);
|
|
36
|
+
|
|
37
|
+
expect(jsonMock).toHaveBeenCalledWith({
|
|
38
|
+
success: false,
|
|
39
|
+
error: {
|
|
40
|
+
code: 'NOT_FOUND',
|
|
41
|
+
message: 'Item not found',
|
|
42
|
+
statusCode: 404,
|
|
43
|
+
details: null,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should include details array from AppException', () => {
|
|
49
|
+
const details = [{ field: 'email', constraints: { isEmail: 'must be email' } }];
|
|
50
|
+
const exception = new AppException({
|
|
51
|
+
code: ErrorCodes.VALIDATION_FAILED,
|
|
52
|
+
message: 'Validation failed',
|
|
53
|
+
statusCode: 400,
|
|
54
|
+
details,
|
|
55
|
+
});
|
|
56
|
+
const host = createMockHost(jsonMock);
|
|
57
|
+
|
|
58
|
+
filter.catch(exception, host);
|
|
59
|
+
|
|
60
|
+
expect(jsonMock).toHaveBeenCalledWith({
|
|
61
|
+
success: false,
|
|
62
|
+
error: {
|
|
63
|
+
code: 'VALIDATION_FAILED',
|
|
64
|
+
message: 'Validation failed',
|
|
65
|
+
statusCode: 400,
|
|
66
|
+
details,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle generic HttpException(403) with FORBIDDEN code', () => {
|
|
72
|
+
const exception = new HttpException('Forbidden', 403);
|
|
73
|
+
const host = createMockHost(jsonMock);
|
|
74
|
+
|
|
75
|
+
filter.catch(exception, host);
|
|
76
|
+
|
|
77
|
+
const call = jsonMock.mock.calls[0][0];
|
|
78
|
+
expect(call.success).toBe(false);
|
|
79
|
+
expect(call.error.statusCode).toBe(403);
|
|
80
|
+
expect(call.error.code).toBe('FORBIDDEN');
|
|
81
|
+
expect(call.error.details).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle generic HttpException(500) with INTERNAL_SERVER_ERROR code', () => {
|
|
85
|
+
const exception = new HttpException('Internal Server Error', 500);
|
|
86
|
+
const host = createMockHost(jsonMock);
|
|
87
|
+
|
|
88
|
+
filter.catch(exception, host);
|
|
89
|
+
|
|
90
|
+
const call = jsonMock.mock.calls[0][0];
|
|
91
|
+
expect(call.success).toBe(false);
|
|
92
|
+
expect(call.error.statusCode).toBe(500);
|
|
93
|
+
expect(call.error.code).toBe('INTERNAL_SERVER_ERROR');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentsHost,
|
|
3
|
+
Catch,
|
|
4
|
+
ExceptionFilter,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { Request, Response } from 'express';
|
|
9
|
+
import { LoggerService } from '../../shared/logger/logger.service';
|
|
10
|
+
|
|
11
|
+
@Catch(HttpException)
|
|
12
|
+
export class HttpExceptionFilter implements ExceptionFilter {
|
|
13
|
+
constructor(private readonly logger: LoggerService) {}
|
|
14
|
+
|
|
15
|
+
catch(exception: HttpException, host: ArgumentsHost): void {
|
|
16
|
+
const ctx = host.switchToHttp();
|
|
17
|
+
const response = ctx.getResponse<Response>();
|
|
18
|
+
const request = ctx.getRequest<Request>();
|
|
19
|
+
|
|
20
|
+
const statusCode = exception.getStatus();
|
|
21
|
+
const res = exception.getResponse() as any;
|
|
22
|
+
|
|
23
|
+
const code = res.error || HttpStatus[statusCode] || 'INTERNAL_ERROR';
|
|
24
|
+
const message = res.message || exception.message;
|
|
25
|
+
|
|
26
|
+
this.logger.error(
|
|
27
|
+
`[${request.method}] ${request.url} - ${statusCode} ${code}: ${message}`,
|
|
28
|
+
exception.stack,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Standardized Error Response
|
|
32
|
+
response.status(statusCode).json({
|
|
33
|
+
success: false,
|
|
34
|
+
error: {
|
|
35
|
+
code,
|
|
36
|
+
message,
|
|
37
|
+
statusCode,
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
path: request.url,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// RPC Exception Filter — placeholder for microservice completeness.
|
|
2
|
+
// Requires @nestjs/microservices to be installed.
|
|
3
|
+
// When @nestjs/microservices is added, uncomment the implementation below.
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
|
|
7
|
+
import { RpcException } from '@nestjs/microservices';
|
|
8
|
+
import { throwError } from 'rxjs';
|
|
9
|
+
|
|
10
|
+
@Catch(RpcException)
|
|
11
|
+
export class RpcExceptionFilter implements ExceptionFilter {
|
|
12
|
+
catch(exception: RpcException, _host: ArgumentsHost) {
|
|
13
|
+
return throwError(() => exception.getError());
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Observable, throwError, TimeoutError } from 'rxjs';
|
|
3
|
+
import { catchError, timeout } from 'rxjs/operators';
|
|
4
|
+
import { AppException } from '../../shared/exceptions/app.exception';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class TimeoutInterceptor implements NestInterceptor {
|
|
10
|
+
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
11
|
+
const timeoutMs = process.env.REQUEST_TIMEOUT
|
|
12
|
+
? parseInt(process.env.REQUEST_TIMEOUT, 10)
|
|
13
|
+
: DEFAULT_TIMEOUT_MS;
|
|
14
|
+
|
|
15
|
+
return next.handle().pipe(
|
|
16
|
+
timeout(timeoutMs),
|
|
17
|
+
catchError((err) => {
|
|
18
|
+
if (err instanceof TimeoutError) {
|
|
19
|
+
return throwError(
|
|
20
|
+
() =>
|
|
21
|
+
new AppException({
|
|
22
|
+
code: 'REQUEST_TIMEOUT',
|
|
23
|
+
message: 'Request timeout',
|
|
24
|
+
statusCode: 408,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return throwError(() => err);
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Reflector } from '@nestjs/core';
|
|
2
|
+
import { of } from 'rxjs';
|
|
3
|
+
import { RAW_RESPONSE_KEY, TransformInterceptor } from './transform.interceptor';
|
|
4
|
+
|
|
5
|
+
function createMockContext(isRaw: boolean) {
|
|
6
|
+
const reflector = new Reflector();
|
|
7
|
+
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(isRaw);
|
|
8
|
+
const context = {
|
|
9
|
+
getHandler: () => jest.fn(),
|
|
10
|
+
getClass: () => jest.fn(),
|
|
11
|
+
} as any;
|
|
12
|
+
return { reflector, context };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('TransformInterceptor', () => {
|
|
16
|
+
it('should wrap controller return value in { success: true, data }', (done) => {
|
|
17
|
+
const { reflector, context } = createMockContext(false);
|
|
18
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
19
|
+
const next = { handle: () => of({ id: '1', name: 'test' }) };
|
|
20
|
+
|
|
21
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
22
|
+
expect(result).toEqual({ success: true, data: { id: '1', name: 'test' } });
|
|
23
|
+
done();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should NOT wrap when @RawResponse() is used', (done) => {
|
|
28
|
+
const { reflector, context } = createMockContext(true);
|
|
29
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
30
|
+
const next = { handle: () => of({ status: 'ok' }) };
|
|
31
|
+
|
|
32
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
33
|
+
expect(result).toEqual({ status: 'ok' });
|
|
34
|
+
done();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should wrap null return value in { success: true, data: null }', (done) => {
|
|
39
|
+
const { reflector, context } = createMockContext(false);
|
|
40
|
+
const interceptor = new TransformInterceptor(reflector);
|
|
41
|
+
const next = { handle: () => of(null) };
|
|
42
|
+
|
|
43
|
+
interceptor.intercept(context, next).subscribe((result) => {
|
|
44
|
+
expect(result).toEqual({ success: true, data: null });
|
|
45
|
+
done();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should use RAW_RESPONSE_KEY constant', () => {
|
|
50
|
+
expect(RAW_RESPONSE_KEY).toBe('raw_response');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { map } from 'rxjs/operators';
|
|
5
|
+
import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
|
|
9
|
+
constructor(private readonly reflector: Reflector) {}
|
|
10
|
+
|
|
11
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
12
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
|
|
13
|
+
context.getHandler(),
|
|
14
|
+
context.getClass(),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Internal first: If not explicitly marked as Public API, return RAW data
|
|
18
|
+
if (!isPublic) {
|
|
19
|
+
return next.handle();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Public API: Wrap response in a standardized success object
|
|
23
|
+
return next.handle().pipe(map((data) => ({ success: true, data })));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CORRELATION_ID_HEADER, CorrelationIdMiddleware } from './correlation-id.middleware';
|
|
2
|
+
|
|
3
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
|
+
|
|
5
|
+
function createMockReq(headers: Record<string, string> = {}): any {
|
|
6
|
+
return { headers: { ...headers } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function createMockRes(): any {
|
|
10
|
+
const headers: Record<string, string> = {};
|
|
11
|
+
return {
|
|
12
|
+
setHeader: (name: string, value: string) => {
|
|
13
|
+
headers[name] = value;
|
|
14
|
+
},
|
|
15
|
+
_headers: headers,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('CorrelationIdMiddleware', () => {
|
|
20
|
+
let middleware: CorrelationIdMiddleware;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
middleware = new CorrelationIdMiddleware();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should generate UUID v4 when X-Correlation-ID header is absent', () => {
|
|
27
|
+
const req = createMockReq();
|
|
28
|
+
const res = createMockRes();
|
|
29
|
+
const next = jest.fn();
|
|
30
|
+
|
|
31
|
+
middleware.use(req, res, next);
|
|
32
|
+
|
|
33
|
+
const correlationId = req.headers[CORRELATION_ID_HEADER.toLowerCase()];
|
|
34
|
+
expect(correlationId).toMatch(UUID_PATTERN);
|
|
35
|
+
expect(next).toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should preserve existing X-Correlation-ID header', () => {
|
|
39
|
+
const req = createMockReq({ 'x-correlation-id': 'abc-123' });
|
|
40
|
+
const res = createMockRes();
|
|
41
|
+
const next = jest.fn();
|
|
42
|
+
|
|
43
|
+
middleware.use(req, res, next);
|
|
44
|
+
|
|
45
|
+
expect(req.headers['x-correlation-id']).toBe('abc-123');
|
|
46
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toBe('abc-123');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should always set X-Correlation-ID on the response header', () => {
|
|
50
|
+
const req = createMockReq();
|
|
51
|
+
const res = createMockRes();
|
|
52
|
+
const next = jest.fn();
|
|
53
|
+
|
|
54
|
+
middleware.use(req, res, next);
|
|
55
|
+
|
|
56
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toBeDefined();
|
|
57
|
+
expect(res._headers[CORRELATION_ID_HEADER]).toMatch(UUID_PATTERN);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should call next()', () => {
|
|
61
|
+
const req = createMockReq();
|
|
62
|
+
const res = createMockRes();
|
|
63
|
+
const next = jest.fn();
|
|
64
|
+
|
|
65
|
+
middleware.use(req, res, next);
|
|
66
|
+
|
|
67
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
});
|