Skip to content

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 response

I3GatewayLogger — custom logger

I3GatewayLogger เป็น custom logger ที่ implement LoggerService interface ของ NestJS — ใช้แทน default logger ของ NestJS

centralize-api/libs/log/logs.middleware.ts:1-68

typescript
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 modeflags: '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

typescript
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

typescript
app.useGlobalPipes(
  new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);
ตัวเลือกค่าผล
whitelisttruestrip property ที่ไม่อยู่ใน DTO class
forbidNonWhitelistedtruethrow 400 ถ้ามี property ที่ไม่อยู่ใน DTO
transformfalseไม่ 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

typescript
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 อยู่ดี

การแก้

typescript
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)ไม่มี
compressiongzip response bodyไม่มี
cookie-parserparse cookie headerไม่มี
body-parserparse JSON body (limit 50mb)มี

ผล — response ไม่ compressed → ใช้ bandwidth สูงกว่าที่ควร, ไม่มี security headers ป้องกัน clickjacking หรือ MIME sniffing


Swagger setup

centralize-api/docs/document.config.ts:1-10

typescript
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

typescript
.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

ผล

typescript
// DTO ในไฟล์ .dto.ts
export class GetEmployeeQueryDto {
  @IsString()
  empCode?: string;
}

// Swagger UI จะแสดง property 'empCode' โดยอัตโนมัติ โดย dev ไม่ต้องเขียน @ApiProperty

introspectComments: true → JSDoc comment จะถูกใช้เป็น description ใน Swagger

typescript
/**
 * หารหัสพนักงานตามเงื่อนไข
 */
export class GetEmployeeQueryDto { ... }

→ Swagger UI แสดง "หารหัสพนักงานตามเงื่อนไข" เป็น description

ปัญหา — DTOs ส่วนใหญ่ใน centralize-api/src/dto/ ไม่มี JSDoc comment → description ว่าง


Response type — ไม่ annotated

(ทบทวนจากบท 3.3.3)

typescript
// สิ่งที่ 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

typescript
@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 + รั่ว PII
  • HttpExceptionFilter@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 ที่หนัก