3.3.7 · Cross-cutting concerns + Swagger
บทนี้อธิบายส่วนที่ตัดผ่านทุก module — custom logger (I3GatewayLogger), exception filter, validation, CORS, และ Swagger documentation setup พร้อมเน้นจุดที่มีปัญหา
Cross-cutting concerns แผนภาพ
HTTP request
│
▼
Express adapter
│
▼
CORS check (origin: '*') ← centralize-api/src/main.ts:31-35
│
▼
Body parser (limit 50mb) ← centralize-api/src/main.ts:24-25
│
▼
LoggingMiddleware (I3GatewayLogger) ← centralize-api/libs/log/logs.middleware.ts
│
▼
JwtAuthGuard (bypass — @Public) ← centralize-api/libs/jwt/jwt-auth.guard.ts
│
▼
ValidationPipe ← centralize-api/src/main.ts:28-30
│
▼
Controller → Service → DB
│
▼ (throws on error)
HttpExceptionFilter ← centralize-api/libs/exception/http-exception.filter.ts
│
▼
JSON responseI3GatewayLogger — custom logger
I3GatewayLogger เป็น custom logger ที่ implement LoggerService interface ของ NestJS — ใช้แทน default logger ของ NestJS
centralize-api/libs/log/logs.middleware.ts:1-68
import { LoggerService } from '@nestjs/common';
import * as moment from 'moment';
import * as fs from 'fs';
export class I3GatewayLogger implements LoggerService {
private moduleName: string;
constructor(moduleName: string) {
this.moduleName = moduleName;
}
logFile = fs.createWriteStream(
`${process.cwd()}/logs/${moment().format('DD-MM-YYYY')}.log`,
{
flags: 'a',
},
);
/**
* Write a 'log' level log.
*/
log(message: any) {
const log = `\x1b[0m${new Date().toISOString()} \x1b[32m| ${
process.env.PROJECT_NAME
} | LOG | \x1b[33m[${this.moduleName}]\x1b[32m `;
console.log(log, message);
this.logFile.write(log + JSON.stringify(message) + '\n');
}
/**
* Write an 'error' level log.
*/
error(message: any) {
const log = `\x1b[0m${new Date().toISOString()} \x1b[31m| ${
process.env.PROJECT_NAME
} | ERROR | \x1b[33m[${this.moduleName}]\x1b[31m `;
console.log(log, message);
this.logFile.write(log + JSON.stringify(message) + '\n');
}
/**
* Write a 'warn' level log.
*/
warn(message: any) {
const log = `\x1b[0m${new Date().toISOString()} \x1b[31m| ${
process.env.PROJECT_NAME
} | WARN | \x1b[33m[${this.moduleName}]\x1b[31m `;
console.log(log, message);
this.logFile.write(log + JSON.stringify(message) + '\n');
}
/**
* Write a 'debug' level log.
*/
debug?(message: any) {
const log = `\x1b[0m${new Date().toISOString()} \x1b[35m| ${
process.env.PROJECT_NAME
} | DEBUG | \x1b[33m[${this.moduleName}]\x1b[35m `;
console.log(log, message);
this.logFile.write(log + JSON.stringify(message) + '\n');
}
info?(message: any) {
const log = `\x1b[0m${new Date().toISOString()} \x1b[36m| ${
process.env.PROJECT_NAME
} | INFO | \x1b[33m[${this.moduleName}]\x1b[36m `;
console.log(log, message);
this.logFile.write(log + JSON.stringify(message) + '\n');
}
}พฤติกรรมเด่น
| ฟีเจอร์ | รายละเอียด |
|---|---|
| Multi-transport | เขียนทั้ง process.stdout (ผ่าน console.log) และไฟล์ |
| หนึ่งไฟล์ต่อวัน | ${cwd}/logs/DD-MM-YYYY.log — ไม่มีการหมุนเวียนไฟล์ (rotation), ไม่มีขนาดสูงสุด |
| Append mode | flags: 'a' — ไม่ truncate ข้อมูลเก่า |
| ANSI colors | \x1b[32m%s\x1b[0m (green) สำหรับ stdout — แต่ถ้าอ่าน log file ด้วย less จะเห็น escape sequence |
| Context prefix | [ClassName] prefix ที่ทุกบรรทัด |
| Level 5 ตัว | log, error, warn, debug, info |
ปัญหาที่สำคัญ
| ปัญหา | ผลกระทบ |
|---|---|
| ไม่มีการหมุนเวียนไฟล์ | ไฟล์โตเรื่อย ๆ — ในรอบเดือนอาจใหญ่กว่า GB → disk full |
| PII รั่วลงไฟล์ | เพราะ logging: ['query'] ของ TypeORM (บท 3.3.2) ทุก SQL และผลลัพธ์ถูก log รวม empCode, fullName, bankAccount |
| ไม่มีการปิดบังข้อมูล (redaction) | logger ไม่ filter pattern เช่น \d{13} (รหัสบัตรประชาชน) → PII ผ่านเข้า log ได้ |
logFile เป็น instance field ที่เปิด stream ตอน construct | ถ้า process รันข้ามวัน ชื่อไฟล์จะตามวันที่ construct เสมอ (ไม่ rotate) → log วันถัดไปจะถูกเขียนลงไฟล์วันเดิม |
| ไม่มี structured logging | ไม่ใช้ JSON format → ค้นหายากใน ELK/Splunk |
การแก้
- ใช้
winstonหรือpinoที่มี rotation ในตัว (เช่นwinston-daily-rotate-file) - ปิด
logging: ['query']ใน TypeORM config (บท 3.3.2) - เพิ่ม middleware ที่ mask pattern ก่อนเขียน log
- ใช้ transport แบบ async ไม่ใช่
console.logที่ block event loop
HttpExceptionFilter — filter ที่จับแค่ HttpException
centralize-api/libs/exception/http-exception.filter.ts:1-26
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';
import { I3GatewayLogger } from 'libs/log/logs.middleware';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private logger = new I3GatewayLogger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const message = exception.getResponse();
this.logger.log(message);
response.status(status).json({
statusCode: status,
message: message['message'] || message['error'],
});
}
}จุดสำคัญ
@Catch(HttpException) — จับแค่ HttpException
filter นี้ catch เฉพาะ exception ที่เป็น subclass ของ HttpException (เช่น BadRequestException, UnauthorizedException, InternalServerErrorException) — ไม่ catch error อื่น เช่น Error, TypeError, QueryFailedError
ผลกระทบ
- ถ้าเกิด error ที่ไม่ใช่
HttpException(เช่นTypeErrorจาก null access) → จะผ่านไปยัง default exception handler ของ NestJS ซึ่งจะ return 500 + stack trace ใน response (ใน production ไม่ควรเกิด) - พฤติกรรมของ error response จะแตกต่างกันระหว่าง error ที่ throw เป็น
BadRequestExceptionกับ error ที่ผ่านออกไป
การแก้ — ใช้ @Catch() (ไม่มี argument) เพื่อ catch ทุก exception
property access ไม่ safe
filter ใช้ message['message'] || message['error'] โดยไม่เช็ค type ของ message — ถ้า exception.getResponse() คืนค่าเป็น string (ไม่ใช่ object) จะได้ undefined ทั้งสอง → response จะมี message: undefined
ข้อสังเกต — v1 เอกสารเก่ากว่าโค้ดปัจจุบัน
v1 บอกว่า filter return { statusCode, timestamp, path, method, ... } และยาว ~46 บรรทัด แต่โค้ดปัจจุบันคืนแค่ { statusCode, message } และยาว 26 บรรทัด — v1 quote เวอร์ชั่นเก่า ต้องอ้างอิงเวอร์ชั่นปัจจุบันแทน
ValidationPipe — ตั้งค่าถูกต้อง
(ทบทวนจากบท 3.3.3)
centralize-api/src/main.ts:28-30
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);| ตัวเลือก | ค่า | ผล |
|---|---|---|
whitelist | true | strip property ที่ไม่อยู่ใน DTO class |
forbidNonWhitelisted | true | throw 400 ถ้ามี property ที่ไม่อยู่ใน DTO |
transform | false | ไม่ cast query param string → number/Date |
การตั้งค่านี้ถูกต้องตามมาตรฐาน NestJS — ป้องกัน mass-assignment และให้ DTO เป็น single source of truth
ปัญหา — transform: false ทำให้ @Type(() => Date) decorator ใน DTO ไม่ทำงาน → dev ต้อง cast เองใน service หรือเสี่ยง runtime error
CORS — wildcard origin
centralize-api/src/main.ts:31-35
app.enableCors({
origin: '*',
allowedHeaders: '*',
methods: 'GET,PUT,POST,DELETE,PATCH,OPTIONS',
});origin: '*' — ทุก origin ผ่าน
origin: '*' ใน CORS หมายความว่า browser จะส่ง Access-Control-Allow-Origin: * ใน response ทำให้ script จาก domain ใดก็ตั้งค่าเรียก centralize-api ได้
ผลกระทบ
- ถ้าผู้ไม่ประสงค์ดีทำหน้าเว็บที่ embed
<script>เรียก centralize-api จาก browser ของเหยื่อ → เรียกได้ (รวมถึง PII endpoints) - ปกติ CORS ป้องกันแค่ browser-side ไม่ใช่ server-to-server → ถ้า caller เป็น server (เช่น vtrc-api) ไม่กระทบ
- แต่ถ้า backoffice front-end เรียก centralize-api ตรงจาก browser → เสี่ยง XSS ใช้ centralize-api เป็นช่องทางดึงข้อมูลออกจากระบบ
allowedHeaders: '*' — wildcard headers
ยอมรับทุก header จากทุก origin — รวมถึง custom header ที่ไม่ได้ define ล่วงหน้า ทำให้ป้องกัน CORS pre-flight ไม่ได้
methods — verb ครบ
ระบุ GET/PUT/POST/DELETE/PATCH/OPTIONS — แม้ว่า centralize-api จะมีแค่ GET + POST จริง → verb อื่นที่ไม่มี route จะได้ 404 อยู่ดี
การแก้
app.enableCors({
origin: process.env.NODE_ENV === 'production'
? ['https://vtrc.redcross.or.th', 'https://vtrcbackoffice.redcross.or.th']
: true,
allowedHeaders: ['Authorization', 'apiKey', 'Content-Type'],
methods: ['GET', 'POST'],
credentials: false,
});Helmet, compression — ขาด
| Middleware | หน้าที่ | centralize-api |
|---|---|---|
helmet | เพิ่ม security headers (CSP, HSTS, X-Frame-Options) | ไม่มี |
compression | gzip response body | ไม่มี |
cookie-parser | parse cookie header | ไม่มี |
body-parser | parse JSON body (limit 50mb) | มี |
ผล — response ไม่ compressed → ใช้ bandwidth สูงกว่าที่ควร, ไม่มี security headers ป้องกัน clickjacking หรือ MIME sniffing
Swagger setup
centralize-api/docs/document.config.ts:1-10
import { DocumentBuilder } from '@nestjs/swagger';
export const documentConfig = new DocumentBuilder()
.setTitle('Centralize API')
.setDescription('Centralize API')
.setVersion('1.0')
.addServer(`http://10.188.128.160:${process.env.PORT || 3000}/`)
.addServer(`http://localhost:${process.env.PORT || 3000}/`)
.addBearerAuth()
.build();วิเคราะห์
| ฟีเจอร์ | สถานะ |
|---|---|
setTitle + setVersion | ตั้งค่าถูกต้อง |
setDescription('Centralize API') | ค่าสั้น — ควรอธิบาย use case ของ service ละเอียดกว่านี้ |
addTag(...) × 4 | ไม่มี — v1 quote เวอร์ชั่นเก่าที่มี addTag('Root'), addTag('Employee') ฯลฯ โค้ดปัจจุบันไม่มีบรรทัด addTag |
addBearerAuth() | ลงทะเบียน Bearer scheme แต่ guard ไม่ทำงาน (บท 3.3.5) → ปุ่ม Authorize ใน Swagger UI ใช้ไม่ได้จริง |
addServer('http://10.188.128.160:...') | hardcode internal IP รั่วใน doc (env-driven ผ่าน process.env.PORT แต่ IP ยังเป็น literal) |
addServer('http://localhost:...') | สำหรับ dev |
Hardcoded internal IP — 10.188.128.160
IP 10.188.128.160 ปรากฏ — ใน .env:SERVER_URL และ Swagger addServer → เปิดเผยโครงสร้าง network ภายในขององค์กร ถ้า swagger doc รั่วออก internet จะบอกผู้ไม่ประสงค์ดีว่า IP ของ centralize-api คืออะไร
การแก้ — ดึงจาก env var ที่ inject ใน bootstrap
.addServer(process.env.SERVER_URL || 'http://localhost:3000')ข้อสังเกต — v1 เอกสารเก่ากว่าโค้ดปัจจุบัน
v1 quote เวอร์ชั่น 4 addServer() (2 คู่ สำหรับ IP และ localhost) setDescription(''), addTag() ×4 — โค้ดปัจจุบันเป็น 2 addServer() (IP + localhost, env-driven PORT), setDescription('Centralize API'), ไม่มี addTag
NestJS Swagger plugin — auto-detect DTO
nest-cli.json เปิด Swagger plugin (บท 3.3.1) → plugin อ่าน DTO class ที่ลงท้ายด้วย .dto.ts แล้วสร้าง @ApiProperty() ให้อัตโนมัติจาก TypeScript type
ผล
// DTO ในไฟล์ .dto.ts
export class GetEmployeeQueryDto {
@IsString()
empCode?: string;
}
// Swagger UI จะแสดง property 'empCode' โดยอัตโนมัติ โดย dev ไม่ต้องเขียน @ApiPropertyintrospectComments: true → JSDoc comment จะถูกใช้เป็น description ใน Swagger
/**
* หารหัสพนักงานตามเงื่อนไข
*/
export class GetEmployeeQueryDto { ... }→ Swagger UI แสดง "หารหัสพนักงานตามเงื่อนไข" เป็น description
ปัญหา — DTOs ส่วนใหญ่ใน centralize-api/src/dto/ ไม่มี JSDoc comment → description ว่าง
Response type — ไม่ annotated
(ทบทวนจากบท 3.3.3)
// สิ่งที่ controller ควรจะเป็น
@Public()
@Get('posTechnical')
@ApiOkResponse({ type: GetTechnicalPositionItemResponseDto, isArray: true })
getTechnicalPositions(
@Query() query: GetTechnicalPositionsQueryDto,
): Promise<GetTechnicalPositionItemResponseDto[]> { ... }ปัจจุบัน controller ไม่มี @ApiOkResponse หรือ return type annotation → Swagger UI แสดง response body เป็น generic any → client ไม่ทราบรูปแบบ response ล่วงหน้า
OpenAPI spec endpoint
หลังจาก boot Swagger spec ที่ generate จะถูก serve ที่ /docs-json (default path ของ SwaggerModule.setup)
/docs— Swagger UI (interactive)/docs-json— raw OpenAPI JSON/docs-yaml— raw OpenAPI YAML
→ client สามารถ download spec จาก /docs-json แล้ว generate TypeScript client ผ่าน openapi-generator ได้
แต่เนื่องจาก response type ไม่ annotated (ด้านบน) → spec ที่ได้จะไม่สมบูรณ์
Health endpoint — ขาด
(ทบทวนจากบท 3.3.2)
ไม่มี /health endpoint → load balancer/Kubernetes probe ต้องใช้ /api ซึ่งรัน SQL ~130 บรรทัด → เปลือง resource และทำให้ probe ช้า
การแก้ — เพิ่ม HealthController
@Controller('health')
export class HealthController {
@Get('live')
liveness() {
return { status: 'ok' };
}
@Get('ready')
async readiness(
@InjectEntityManager('center') centerEM: EntityManager,
@InjectEntityManager('nbc') nbcEM: EntityManager,
) {
try {
await Promise.all([
centerEM.query('SELECT 1'),
nbcEM.query('SELECT 1'),
]);
return { status: 'ok', databases: { center: 'ok', nbc: 'ok' } };
} catch (error) {
throw new ServiceUnavailableException({ status: 'fail', error: error.message });
}
}
}สรุปประเด็นสำคัญ
I3GatewayLogger— custom logger ที่เขียนลงไฟล์ทุกวัน แต่ ไม่มี rotation, ไม่มี redaction, ไม่ rotate ข้ามวัน (ชื่อไฟล์ตอน construct เสมอ) → เสี่ยง disk full + รั่ว PIIHttpExceptionFilter—@Catch(HttpException)จับแค่ HttpException → error อื่นจะผ่านไปยัง default handler (แก้ไขจาก v1 ที่ quote เวอร์ชั่น 46 บรรทัด — จริง ๆ ปัจจุบัน 26 บรรทัด คืน{ statusCode, message })- ValidationPipe — ตั้งค่าถูกต้อง (
whitelist: true,forbidNonWhitelisted: true) แต่transform: falseทำให้ cast ไม่ auto - CORS
origin: '*'— ทุก origin ผ่าน → เสี่ยงถูกดึงข้อมูลจากทาง browser-side - Helmet, compression, cookie-parser ขาด — ไม่มีการเตรียมพร้อมสำหรับ production
- Swagger setup ดีในระดับหนึ่ง — DTO auto-detect ผ่าน plugin แต่ response type ไม่ annotated + internal IP hardcoded + BearerAuth scheme หลอก (เพราะ guard bypass) + ไม่มี
addTagแล้วในโค้ดปัจจุบัน (แก้ไขจาก v1) - ไม่มี health endpoint → load balancer ต้องยิง
/apiที่หนัก