Skip to content

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 response

I3GatewayLogger — custom logger

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

typescript
// การใช้งาน
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 ได้
เขียนแบบ synchronousfs.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

11:46:centralize-api/libs/exception/http-exception.filter.ts
@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)

28:30:centralize-api/src/main.ts
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

31:35:centralize-api/src/main.ts
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'],  // ตรงกับ endpoints จริง
  credentials: false,        // ไม่ใช้ cookie auth
});

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

1:25:centralize-api/docs/document.config.ts
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

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

ผล

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 ส่วนใหญ่ไม่มี JSDoc comment → description ว่าง


Response type — ไม่ annotated

(ทบทวนจากบท 5.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 — ขาด

(ทบทวนจากบท 5.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, เขียนแบบ synchronous → เสี่ยง disk full + รั่ว PII
  • HttpExceptionFilter@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 ที่หนัก