5.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: '*') ← main.ts:31
│
▼
Body parser (limit 50mb) ← main.ts:25-27
│
▼
LoggingMiddleware (I3GatewayLogger) ← logs.middleware.ts
│
▼
JwtAuthGuard (bypass — @Public) ← jwt-auth.guard.ts
│
▼
ValidationPipe ← main.ts:28-30
│
▼
Controller → Service → DB
│
▼ (throws on error)
HttpExceptionFilter ← http-exception.filter.ts
│
▼
JSON responseI3GatewayLogger — custom logger
I3GatewayLogger เป็น custom logger ที่ implement LoggerService interface ของ NestJS — ใช้แทน default logger ของ NestJS
// การใช้งาน
const logger = new I3GatewayLogger('System');
logger.log('Hello'); // → 2025-...-console.log
logger.error('Oops'); // → 2025-...-error.logพฤติกรรมเด่น
| ฟีเจอร์ | รายละเอียด |
|---|---|
| Multi-transport | เขียนทั้ง process.stdout และไฟล์ |
| หนึ่งไฟล์ต่อวัน | ${cwd}/logs/DD-MM-YYYY.log — ไม่มีการหมุนเวียนไฟล์ (rotation), ไม่มีขนาดสูงสุด |
| Append mode | ไม่ truncate ข้อมูลเก่า |
| ANSI colors | \x1b[32m%s\x1b[0m (green) สำหรับ stdout — แต่ถ้าอ่าน log file ด้วย less จะเห็น escape sequence |
| Context prefix | [ClassName] prefix ที่ทุกบรรทัด |
| Level 4 ตัว | log, error, warn, debug |
ปัญหาที่สำคัญ
| ปัญหา | ผลกระทบ |
|---|---|
| ไม่มีการหมุนเวียนไฟล์ | ไฟล์โตเรื่อย ๆ — ในรอบเดือนอาจใหญ่กว่า GB → disk full |
| PII รั่วลงไฟล์ | เพราะ logging: ['query'] ของ TypeORM (บท 5.2) ทุก SQL และผลลัพธ์ถูก log รวม empCode, fullName, bankAccount |
| ไม่มีการปิดบังข้อมูล (redaction) | logger ไม่ filter pattern เช่น \d{13} (รหัสบัตรประชาชน) → PII ผ่านเข้า log ได้ |
| เขียนแบบ synchronous | fs.appendFileSync — block event loop ขณะเขียน → กระทบประสิทธิภาพถ้า log เยอะ |
| ไม่มี structured logging | ไม่ใช้ JSON format → ค้นหายากใน ELK/Splunk |
การแก้
- ใช้
winstonหรือpinoที่มี rotation ในตัว - ปิด
logging: ['query']ใน TypeORM config (บท 5.2) - เพิ่ม middleware ที่ mask pattern ก่อนเขียน log
HttpExceptionFilter — filter ที่จับแค่ HttpException
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
// ... more fields
});
}
}จุดสำคัญ
@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 ใช้ request.url, request.method โดยไม่เช็ค null — ถ้า host.switchToHttp().getRequest() คืนค่าผิดปกติ (เช่น GraphQL context) จะ crash ในตัว filter เอง
ValidationPipe — ตั้งค่าถูกต้อง
(ทบทวนจากบท 5.3)
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
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'], // ตรงกับ endpoints จริง
credentials: false, // ไม่ใช้ cookie auth
});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
import { DocumentBuilder } from '@nestjs/swagger';
export const documentConfig = new DocumentBuilder()
.setTitle('Centralize API')
.setDescription('')
.setVersion('1.0')
.addTag('Root')
.addTag('Employee')
.addTag('HR Pay Slip')
.addTag('HR Time Temp')
.addBearerAuth()
.addServer('http://10.188.128.160:3000', 'Centralize API')
.addServer('http://10.188.128.160:3000/api')
.addServer('http://localhost:3000')
.addServer('http://localhost:3000/api')
.build();วิเคราะห์
| ฟีเจอร์ | สถานะ |
|---|---|
setTitle + setVersion | ✅ |
setDescription('') | ❌ ค่าว่าง — ควรอธิบาย use case ของ service |
addTag(...) × 4 | ✅ ตรงกับ @ApiTags ใน controller |
addBearerAuth() | ⚠️ ลงทะเบียน Bearer scheme แต่ guard ไม่ทำงาน (บท 5.5) → ปุ่ม Authorize ใน Swagger UI ใช้ไม่ได้จริง |
addServer('http://10.188.128.160:3000', ...) × 2 | ❌ hardcode internal IP รั่วใน doc |
addServer('http://localhost:3000', ...) × 2 | ✅ สำหรับ dev |
Hardcoded internal IP — 10.188.128.160
IP 10.188.128.160 ปรากฏ 3 ครั้ง — ใน .env:SERVER_URL, Swagger addServer × 2 → เปิดเผยโครงสร้าง network ภายในขององค์กร ถ้า swagger doc รั่วออก internet จะบอกผู้ไม่ประสงค์ดีว่า IP ของ centralize-api คืออะไร
การแก้ — ดึงจาก env var ที่ inject ใน bootstrap
.addServer(process.env.SERVER_URL || 'http://localhost:3000')NestJS Swagger plugin — auto-detect DTO
nest-cli.json เปิด Swagger plugin (บท 5.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 ส่วนใหญ่ไม่มี JSDoc comment → description ว่าง
Response type — ไม่ annotated
(ทบทวนจากบท 5.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 — ขาด
(ทบทวนจากบท 5.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, เขียนแบบ synchronous → เสี่ยง disk full + รั่ว PIIHttpExceptionFilter—@Catch(HttpException)จับแค่ HttpException → error อื่นจะผ่านไปยัง default handler- 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)
- ไม่มี health endpoint → load balancer ต้องยิง
/apiที่หนัก