Bỏ qua để đến nội dung
Knowledge Hub

Custom decorator và Reflector trong NestJS: vì sao @Public() trên controller không có tác dụng Trung cấp

Thời gian đọc ước tính: 9 phút

API đơn hàng có một guard xác thực đăng ký global. GET /catalog phải công khai, GET /orders/101 cần role support, còn POST /orders/101/refund chỉ cho admin. Handler đang đọc req.user trực tiếp ở hàng chục chỗ.

Đội thêm @Public() lên CatalogController, nhưng request không token vẫn nhận 401. Đặt @Roles(['support']) ở class và @Roles(['admin']) ở refund() thì không rõ user sam (role support) có được hoàn tiền hay không.

Bài trả lời: decorator lưu thông tin ở đâu, guard đọc lại thế nào, và handler ghi đè hay gộp với class?

Tình huống Chọn Điều kiện
Cờ bật/tắt (@Public()), method thắng class getAllAndOverride(key, [handler, class]) Đọc cả handler và class
Danh sách quyền, method cụ thể hơn class getAllAndOverride Method thay hẳn yêu cầu của class
Danh sách cộng dồn (tag, scope bổ sung) getAllAndMerge Kiểm tra mảng rỗng, không dùng như boolean
Decorator metadata mới Reflector.createDecorator<T>() Nest ≥ 10.2; key tự sinh, không đụng nhau
Lấy user trong handler createParamDecorator → @CurrentUser() Guard đã gắn req.user trước đó
Gộp nhiều decorator hay đi cùng nhau applyDecorators(...) Không có logic điều kiện bên trong

Với experimentalDecorators (cấu hình NestJS dùng), decorator chỉ là một hàm. TypeScript gọi nó một lần, lúc class được định nghĩa, tức lúc file được nạp, không phải mỗi request. Demo in dòng này trước khi có app nào được tạo:

[định nghĩa class] decorator chạy cho OrdersController.get
[định nghĩa class] xong, chưa có app nào được tạo

Vì vậy decorator không biết request hay user. Việc của nó là ghi metadata: SetMetadata(key, value) gọi Reflect.defineMetadata (thư viện reflect-metadata). Đặt ở method thì metadata gắn vào hàm handler (descriptor.value); đặt ở class thì gắn vào constructor.

Thành phần Chạy lúc Việc
SetMetadata / Reflector.createDecorator Định nghĩa class Ghi key → value lên handler hoặc class
Reflector (provider của @nestjs/core) Mỗi request, trong guard/interceptor Đọc metadata theo key
createParamDecorator Định nghĩa: đăng ký tham số; request: chạy factory Tạo giá trị tham số handler
applyDecorators Định nghĩa class Gọi lần lượt nhiều decorator

Các cách đọc thường dùng của Reflector. Ví dụ dưới là kết quả thật với refund() mang [admin], class mang [support]:

Lời gọi Kết quả Quy tắc
get(Roles, handler) ['admin'] Chỉ một target
get(Roles, class) ['support'] Chỉ một target
getAllAndOverride(Roles, [handler, class]) ['admin'] Giá trị khác undefined đầu tiên theo thứ tự mảng
getAllAndMerge(Roles, [handler, class]) ['admin', 'support'] Mảng nối nhau, object gộp nông ({...a, ...b})

Thứ tự [handler, class] là quy ước, không phải mặc định ngầm. Đảo thành [class, handler] thì class thắng.

Hình dưới trả lời: metadata được ghi lúc nào, và với sam gọi refund(), guard quyết định dựa trên giá trị nào.

Decorator ghi metadata một lần; RolesGuard đọc handler trước class, override cho 403 còn merge cho 200
Decorator ghi metadata một lần; RolesGuard đọc handler trước class, override cho 403 còn merge cho 200Bước 1/7: Nạp file controller: decorator chạy, ghi roles [support] lên class và [admin] lên refund().Lúc định nghĩa class(một lần)Mỗi request@Roles(...)class + refund()Metadatareflect-metadata2 bản ghiReflectorgetAllAndOverrideRequesttoken samRolesGuardAPP_GUARDrefund()handlerdefineMetadata
@Roles(...) → MetadatadefineMetadata
Bước 1/7

Nạp file controller: decorator chạy, ghi roles [support] lên class và [admin] lên refund().

Bước Metadata Guard thấy Kết quả
1. Nạp file class [support], refund [admin] Chưa có guard
2–3. Bootstrap, request đến Không đổi req.user = sam (roles [support])
4–5. Override Không đổi [admin] (dừng ở handler) 403
6–7. Merge Không đổi [admin, support] 200

Metadata không đổi theo request. Muốn quyết định theo user thì guard kết hợp metadata với req.user, không ghi ngược vào metadata.

Code decorator trong demo:

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const Roles = Reflector.createDecorator<string[]>();
export const AdminOnly = () => applyDecorators(Roles(['admin']), HttpCode(200));
export const CurrentUser = createParamDecorator((data: keyof User | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest<{ user?: User }>();
return data ? req.user?.[data] : req.user;
});

Guard đọc lại:

// AuthGuard (APP_GUARD)
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);
if (isPublic) return true;
// RolesGuard: truyền chính decorator, kiểu trả về là string[]
const required = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);

Reflector.createDecorator<string[]>() (thêm ở v10.2.0, PR #12237) trả về decorator có thuộc tính KEY. Nếu không truyền key, Nest sinh chuỗi ngẫu nhiên 21 ký tự, nên hai module không thể trùng key. Truyền decorator vào reflector.get(Roles, ...) thì TypeScript suy ra string[], không cần generic thủ công.

  • ExecutionContext.switchToHttp().getRequest() trả request của adapter (Express mặc định). Với GraphQL hay microservice, context khác; getHandler() và getClass() vẫn dùng được.
  • Pipe áp dụng cho tham số custom như @Body(). Nhưng ValidationPipe mặc định bỏ qua chúng; cần validateCustomDecorators: true.
  • createParamDecorator nhận pipe sau data: @CurrentUser('id', ParseIntPipe), hoặc pipe ở vị trí đầu nếu không có data.
  • createDecorator gọi không tham số thì lưu {} (code: value ?? {}), truthy. Cờ boolean nên truyền giá trị rõ ràng hoặc dùng SetMetadata(key, true).
  • getAllAndMerge với một giá trị nguyên thủy duy nhất trả về mảng ([true]); không có giá trị nào thì trả [].
  • Guard global qua APP_GUARD được DI inject Reflector. Đăng ký bằng app.useGlobalGuards(new AuthGuard(...)) thì phải tự truyền Reflector (xem DI container).

@CurrentUser() chạy ở bước pipe, sau mọi guard. Hình dưới cho thấy vì sao guard không dùng được decorator này:

Đang tải diagram…

Demo orders-demo.ts (NestJS 11.2.6) dựng app thật, listen(0), gọi bằng fetch. Token là tên user (chỉ cho demo):

== AuthGuard đọc handler + class, RolesGuard dùng getAllAndOverride
GET /catalog - -> 200 {"items":["sku-1","sku-2"]}
GET /orders/health - -> 200 {"ok":true}
GET /orders/101 - -> 401 {"message":"Unauthorized","statusCode":401}
GET /orders/101 alice -> 403 {"message":"cần một trong [support]",...}
GET /orders/101 sam -> 200 {"id":"101","viewedBy":"sam"}
POST /orders/101/refund sam -> 403 {"message":"cần một trong [admin]",...}
POST /orders/101/refund root -> 200 {"id":"101","refundedBy":"root"}
GET /orders/me sam -> 200 {"id":2}
GET /orders/me ghost -> 400 {"message":["id must be an integer number"],...}
== RolesGuard dùng getAllAndMerge
POST /orders/101/refund sam -> 200 {"id":"101","refundedBy":"sam"}
== Bug: AuthGuard chỉ đọc handler
GET /catalog - -> 401 {"message":"Unauthorized","statusCode":401}
GET /orders/health - -> 200 {"ok":true}

Ba điều đọc ra:

  1. @Public() ở class (/catalog) và ở method (/orders/health) đều có tác dụng chỉ khi guard đọc [handler, class].
  2. Cùng metadata, override cho sam 403, merge cho 200. Ngữ nghĩa quyền nằm ở cách guard đọc, không ở decorator.
  3. @CurrentUser(new ValidationPipe({ validateCustomDecorators: true })) chặn claim hỏng (id: 'x') bằng 400.
Terminal window
npx --prefix examples ts-node examples/src/nestjs-custom-decorators/orders-demo.ts

Ưu điểm

  • Quy tắc truy cập nằm cạnh route, đọc code là thấy route nào public, cần role gì.

  • Một guard global xử lý mọi route; handler không lặp if (!req.user).

  • createDecorator cho kiểu chặt và key không trùng.

  • @CurrentUser() tách handler khỏi Express req, dễ test.

  • < slot="cons">
  • Quy tắc ẩn: thiếu decorator là route bị chặn (hoặc lộ) mà không có lỗi compile.

  • Ngữ nghĩa override/merge do guard quyết định; đổi guard là đổi quyền của mọi route.

  • Metadata tĩnh, không diễn đạt được quyền theo dữ liệu (đơn của user này).

  • Decorator chồng nhiều lớp khó debug khi không biết thứ tự đọc.

Nhược điểm

Quyền theo dữ liệu (user chỉ xem đơn của mình) vẫn phải kiểm tra trong service, xem Secure coding.

  1. Guard global đọc metadata bằng [ctx.getHandler(), ctx.getClass()], có test cho @Public() ở cả class và method.
  2. Chọn override hoặc merge theo nghĩa nghiệp vụ, ghi rõ trong comment của guard.
  3. Dùng Reflector.createDecorator<T>() cho decorator mới; key string thì đặt tiền tố module (orders:roles).
  4. Mặc định từ chối: route không có metadata vẫn phải qua xác thực.
  5. Bật validateCustomDecorators nếu muốn ValidationPipe kiểm tra giá trị từ @CurrentUser().
  6. Gom bộ decorator lặp lại (Roles + HttpCode + Swagger) bằng applyDecorators.
  7. Có test e2e liệt kê route public để phát hiện route bị lộ khi thêm decorator mới.

Lỗi phổ biến nhất là guard global chỉ đọc handler, đúng như phần “Bug” của demo:

@Public() trên controller bị bỏ qua

Hay gặp

const isPublic = this.reflector.get<boolean>(IS_PUBLIC_KEY, ctx.getHandler());

Nên viết

const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);

Vì sao

@Public() trên class ghi metadata vào constructor, không vào từng method. Đọc mỗi handler thì /catalog vẫn 401.

Lỗi thứ hai nguy hiểm hơn vì nó mở route thay vì chặn:

Cách hiểu sai Hệ quả Cần làm
Decorator chạy mỗi request Đặt logic theo user trong decorator Decorator chỉ ghi metadata; logic ở guard
Key 'roles' chỉ module mình dùng Thư viện khác ghi đè, guard đọc nhầm createDecorator hoặc tiền tố key
Dùng @CurrentUser() trong guard Không có: param factory chạy sau guard Guard đọc req.user
Merge an toàn hơn override Merge mở rộng quyền (sam refund được) Chọn theo nghĩa, có test
  1. Đảo targets thành [ctx.getClass(), ctx.getHandler()], sam gọi refund nhận mã gì với override?
  2. Vì sao @Public() trên class không có tác dụng khi guard dùng get(..., ctx.getHandler())?
  3. Vì sao @Roles() từ createDecorator ở hai module khác nhau không bao giờ đè metadata của nhau?
  4. Guard cần user id. Đọc từ đâu, và vì sao không phải @CurrentUser()?