Spring REST API & Data JPA/ArgumentResolver — chuỗi resolver, type conversion, custom annotation
9/46
Bài 9 / 46~13 phútRequest & ResponseMiễn phí lượt xem

ArgumentResolver — chuỗi resolver, type conversion, custom annotation

Spring MVC resolve từng parameter method controller qua chuỗi HandlerMethodArgumentResolver. Bài này bóc cơ chế supportsParameter/resolveArgument, ConversionService cho type conversion (String→int, String→LocalDate, String→Enum), và cách tự viết custom resolver cho annotation @CurrentUser — giải thích tại sao thiết kế này mở rộng được và pitfall mass assignment.

TL;DR: Khi Spring MVC gọi một controller method, nó không truyền thẳng HttpServletRequest vào — thay vào đó RequestMappingHandlerAdapter iterate qua một danh sách HandlerMethodArgumentResolver, hỏi từng resolver "mày handle được param này không?" qua supportsParameter(), rồi để resolver nào trả true thực hiện resolveArgument() trả về giá trị Java. Type conversion (String → int, LocalDate, Enum) chạy qua ConversionService bên trong resolver. Thiết kế này cho phép thêm resolver mới mà không sửa code Spring — đó là lý do @CurrentUser custom hoạt động như annotation built-in. Pitfall cốt lõi: mass assignment khi DTO expose field nhạy cảm cho client gán.

Bài Request binding đã trình bày 6 annotation source (@PathVariable, @RequestParam, @RequestBody…). Bài này bóc tầng bên dưới chúng: chuỗi resolver chạy ra sao, type conversion xảy ra ở đâu, và vì sao bạn có thể tự thêm resolver mà framework không cần biết.

1. Bức tranh tổng: request đến controller

Trước khi mổ resolver, cần nhìn toàn bộ con đường một request đi qua:

flowchart TB
  Req["HTTP Request"] --> DS["DispatcherServlet"]
  DS --> RMHA["RequestMappingHandlerAdapter"]
  RMHA --> Resolvers["HandlerMethodArgumentResolver list<br/>(iterate cho moi param)"]
  Resolvers --> Method["controller.method(arg1, arg2, ...)"]
  Method --> HMReturnValueHandler["HandlerMethodReturnValueHandler"]
  HMReturnValueHandler --> Res["HTTP Response"]

RequestMappingHandlerAdapter là trái tim — nó nhận handler method đã match URL, rồi làm hai việc: resolve argument (bài này) và handle return value (bài ResponseEntity & HTTP status codes). Bài này tập trung nửa đầu.

2. Cơ chế bên dưới — chuỗi HandlerMethodArgumentResolver

HandlerMethodArgumentResolver là interface SPI (Service Provider Interface) — ai implement được, Spring chấp nhận:

// org/springframework/web/method/support/HandlerMethodArgumentResolver.java
public interface HandlerMethodArgumentResolver {

    // Buoc 1: Spring hoi "nguoi nay co handle duoc param nay khong?"
    boolean supportsParameter(MethodParameter parameter);

    // Buoc 2: neu supportsParameter() tra true, Spring goi de lay gia tri
    Object resolveArgument(
        MethodParameter parameter,
        ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest,
        WebDataBinderFactory binderFactory
    ) throws Exception;
}

Tại sao thiết kế này mở rộng được? Spring đăng ký khoảng 30+ resolver built-in (mỗi annotation một resolver). Khi bạn thêm resolver custom, nó xếp vào đầu danh sách — Spring iterate từ đầu, resolver nào trả true trước thì được gọi. Không cần sửa Spring, không cần subclass gì — chỉ thêm vào list. Đây là Open/Closed Principle ở framework level.

Luồng đầy đủ khi RequestMappingHandlerAdapter invoke method:

flowchart TB
  Param["MethodParameter: @PathVariable Long id"] --> Iter["iterate resolver list"]
  Iter --> Q1{"supportsParameter()"}
  Q1 -->|"false"| Next["next resolver"]
  Next --> Q1
  Q1 -->|"true"| Resolve["resolveArgument()<br/>lay gia tri tu request"]
  Resolve --> Value["Long 42"]
  Value --> Invoke["method(42, ...)"]

Mỗi parameter được xử lý độc lập — 5 param trong method signature thì 5 lần iterate resolver list. Thứ tự resolver quyết định ai thắng khi hai resolver cùng supportsParameter() trả true (hiếm nhưng xảy ra khi custom resolver không kiểm tra kỹ).

2.1 Các resolver built-in quan trọng

AnnotationResolver class
@PathVariablePathVariableMethodArgumentResolver
@RequestParamRequestParamMethodArgumentResolver
@RequestBodyRequestResponseBodyMethodProcessor
@RequestHeaderRequestHeaderMethodArgumentResolver
@CookieValueServletCookieValueMethodArgumentResolver
@RequestAttributeRequestAttributeMethodArgumentResolver
@AuthenticationPrincipalAuthenticationPrincipalArgumentResolver (Spring Security)
PageablePageableHandlerMethodArgumentResolver (Spring Data)

Pageable không có annotation — resolver của nó dùng type check thay annotation: supportsParameter() trả true khi parameter.getParameterType() == Pageable.class.

3. Type conversion — String vào Java type

HTTP chỉ truyền byte. Query param, path variable, header — tất cả đều là String ở tầng HTTP. Spring cần convert "42" → Long, "2026-04-01" → LocalDate, "ACTIVE" → OrderStatus.

Cơ chế đứng sau: ConversionService — một registry các Converter<S, T>Formatter<T>. Resolver gọi ConversionService.convert(stringValue, targetType) trước khi trả về giá trị.

flowchart LR
  Raw["String: '42'<br/>(tu URL path)"] --> CS["ConversionService"]
  CS --> Conv["StringToNumberConverter<br/>(built-in)"]
  Conv --> Result["Long: 42"]

3.1 Conversion built-in

Spring Boot auto-configure DefaultConversionService với nhiều converter sẵn:

String inputJava targetConverter / cơ chế
"42"int, Integer, Long, DoubleStringToNumberConverter
"2026-04-01"LocalDateDateTimeFormatAnnotationFormatterFactory + ISO default Boot 3
"2026-04-01T10:00:00"LocalDateTimeTương tự
"550e8400-..."UUIDStringToUUIDConverter
"ACTIVE"OrderStatus (enum)StringToEnumConverter — dùng Enum.valueOf(name), case-sensitive
"true" / "false"booleanStringToBooleanConverter

Khi conversion thất bại (vd ?id=abc cho Long), resolver throw MethodArgumentTypeMismatchException và Spring map thành 400 Bad Request. Fail-fast an toàn.

3.2 Enum conversion — case-sensitive pitfall

public enum OrderStatus { PENDING, ACTIVE, COMPLETED, CANCELLED }

@GetMapping("/orders")
public List<OrderDto> list(@RequestParam OrderStatus status) { ... }

Với request ?status=active, StringToEnumConverter gọi OrderStatus.valueOf("active") và nhận IllegalArgumentException — client lãnh 400. Default case-sensitive"ACTIVE" đúng, "active" sai.

Fix: custom Converter<String, OrderStatus>:

@Component
public class OrderStatusConverter implements Converter<String, OrderStatus> {

    public OrderStatus convert(String source) {
        try {
            return OrderStatus.valueOf(source.toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new MethodArgumentTypeMismatchException(
                source, OrderStatus.class, "status", null, e
            );
        }
    }
}

Spring Boot tự detect @Component implement Converter và đăng ký vào ConversionService. Không cần config thêm.

3.3 Date format — ISO vs custom

Boot 3 mặc định parse LocalDate theo ISO 8601 (yyyy-MM-dd). Format khác cần @DateTimeFormat:

@GetMapping("/orders")
public List<OrderDto> list(
    // ISO default -- Boot 3 khong can annotation
    @RequestParam LocalDate from,

    // Custom format -- phai khai @DateTimeFormat
    @RequestParam @DateTimeFormat(pattern = "dd/MM/yyyy") LocalDate to
) { ... }

Annotation @DateTimeFormat không liên quan Bean Validation — nó chỉ hướng dẫn ConversionService dùng DateTimeFormatter nào để parse String.

3.4 Custom Converter cho type phức tạp

Khi cần parse "USD:42.50"Money record:

public record Money(String currency, BigDecimal amount) {
    public static Money parse(String s) {
        String[] parts = s.split(":");
        return new Money(parts[0], new BigDecimal(parts[1]));
    }
}

@Component
public class MoneyConverter implements Converter<String, Money> {
    public Money convert(String source) {
        return Money.parse(source);   // throw IllegalArgumentException -> 400
    }
}

// Use:
@GetMapping("/products")
public List<ProductDto> list(@RequestParam Money priceRange) { ... }
// ?priceRange=USD:10.00

Một lần đăng ký — dùng được mọi endpoint.

4. Custom ArgumentResolver — @CurrentUser

Scenario: 80% endpoint cần "user hiện tại" từ JWT. Nếu mỗi method tự parse SecurityContextHolder, code lặp và khó test.

// Truoc: verbose, kho test
@GetMapping("/profile")
public UserDto profile() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    User user = (User) auth.getPrincipal();
    return UserDto.from(user);
}

Custom resolver encapsulate logic một lần, dùng qua annotation:

Bước 1 — định nghĩa annotation:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}

@Retention(RUNTIME) bắt buộc — annotation phải còn khi resolver đọc qua reflection.

Bước 2 — implement resolver:

@Component
public class CurrentUserResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // Chi nhan param co annotation @CurrentUser
        return parameter.hasParameterAnnotation(CurrentUser.class);
    }

    @Override
    public Object resolveArgument(
        MethodParameter parameter,
        ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest,
        WebDataBinderFactory binderFactory
    ) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            throw new IllegalStateException("No authenticated user in context");
        }
        return auth.getPrincipal();   // tra ve User object
    }
}

Bước 3 — đăng ký vào Spring MVC:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private CurrentUserResolver currentUserResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(currentUserResolver);   // them vao dau list
    }
}

Bước 4 — sử dụng:

// Sau: concise, declarative, de test
@GetMapping("/profile")
public UserDto profile(@CurrentUser User user) {
    return UserDto.from(user);
}

@PostMapping("/orders")
public OrderDto create(@CurrentUser User user, @Valid @RequestBody OrderRequest req) {
    return orderService.create(user, req);
}

4.1 Vì sao thiết kế này tốt hơn @RequestAttribute

So sánh 3 cách inject current user:

CáchCode tại controllerTestabilityType safety
SecurityContextHolder inlineVerbose, lặp 80 methodKhó — ThreadLocalCast thủ công
@RequestAttribute("user")String key dễ typoMock request attributeKhông — Object
@CurrentUser custom resolver@CurrentUser User userMock resolver beanCompile-time

Custom resolver thắng về tất cả: declarative, type-safe, test chỉ cần mock hoặc inject resolver trong WebMvcTest slice.

Spring Security co @AuthenticationPrincipal built-in

Nếu app dùng Spring Security, @AuthenticationPrincipal là annotation built-in tương đương — resolver của nó (AuthenticationPrincipalArgumentResolver) đã đăng ký tự động. Dùng @AuthenticationPrincipal cho trường hợp chuẩn. Custom @CurrentUser khi cần logic resolve phức tạp hơn (vd merge dữ liệu từ DB, multi-tenant context).

5. Pitfall — mass assignment vulnerability

Mass assignment xảy ra khi DTO expose field client không được phép gán, và resolver bind toàn bộ JSON body vào DTO đó.

// DTO nguy hiem
public record UserUpdateRequest(
    String name,
    String email,
    boolean isAdmin,          // CLIENT KHONG DUOC SET truong nay
    String role               // Tuong tu
) {}

@PutMapping("/users/{id}")
public UserDto update(@PathVariable Long id,
                      @RequestBody UserUpdateRequest req) {
    userService.update(id, req);
}

Client gửi:

{
  "name": "Alice",
  "email": "[email protected]",
  "isAdmin": true,
  "role": "ADMIN"
}

Jackson bind toàn bộ body vào record — isAdmin = true, role = "ADMIN" được gán. Đây là privilege escalation — lỗ hổng nghiêm trọng.

Fix đúng — split DTO theo authority:

// DTO cho user thuong
public record UserPublicUpdate(
    @NotBlank String name,
    @Email String email
) {}

// DTO cho admin (endpoint rieng, role rieng)
public record UserAdminUpdate(
    @NotBlank String name,
    @Email String email,
    boolean isAdmin,
    String role
) {}

@PutMapping("/users/{id}")
@PreAuthorize("hasRole('USER')")
public UserDto update(@PathVariable Long id,
                      @RequestBody @Valid UserPublicUpdate req) {
    return userService.updatePublic(id, req);
}

@PutMapping("/admin/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public UserDto adminUpdate(@PathVariable Long id,
                           @RequestBody @Valid UserAdminUpdate req) {
    return userService.updateAdmin(id, req);
}

Vì sao @JsonIgnore không đủ an toàn: @JsonIgnore ngăn serialize, nhưng theo config Jackson, đôi khi vẫn deserialize được nếu setter tồn tại hoặc có annotation override. Cách duy nhất tin cậy: DTO không chứa field nhạy cảm từ đầu.

Pitfall: Payload size va JSON depth

Jackson default không giới hạn độ sâu nested JSON. Request {"a":{"b":{"c":...}}} lồng 100,000 cấp gây StackOverflow — DoS attack. Production cần configure StreamReadConstraints:

@Bean
public ObjectMapper objectMapper() {
  return JsonMapper.builder()
      .configure(StreamReadConstraints.builder()
          .maxNestingDepth(20)
          .maxStringLength(5_000_000)
          .build())
      .build();
}

Xem chi tiết trong bài Request binding — production hardening.

6. Cơ chế bên dưới — resolver registration và ordering

Khi Spring Boot khởi động, WebMvcAutoConfiguration tạo RequestMappingHandlerAdapter và đăng ký resolver theo thứ tự cố định. Custom resolver qua addArgumentResolvers() được thêm vào đầu list (ưu tiên cao hơn built-in).

flowchart TB
  Boot["Spring Boot startup"] --> RMHA["RequestMappingHandlerAdapter init"]
  RMHA --> BuiltIn["built-in resolvers<br/>(PathVariable, RequestParam, RequestBody...)"]
  RMHA --> Custom["WebMvcConfigurer.addArgumentResolvers()<br/>xu ly truoc built-in"]
  Custom --> List["resolver list<br/>[CustomResolver, ..., PathVariableResolver, ...]"]

Hệ quả thứ tự: nếu custom resolver có supportsParameter() quá rộng (vd return true cho mọi param), nó sẽ intercept cả param built-in. Kiểm tra annotation cụ thể (hasParameterAnnotation) hoặc type cụ thể (getParameterType() == MyType.class) để tránh conflict.

Tại sao không kế thừa resolver built-in? Mỗi resolver đã optimize cho annotation/type cụ thể. Kế thừa → phụ thuộc implementation detail có thể thay đổi giữa Spring versions. Compose annotation mới + resolver riêng an toàn hơn.

7. Liên hệ các bài khác

  • Request binding: bài trước trình bày 6 annotation source (@PathVariable, @RequestParam…). Bài này giải thích cơ chế bên dưới mà 6 annotation đó dựa vào — mỗi annotation là một HandlerMethodArgumentResolver implementation.
  • ResponseEntity & HTTP status codes: bài tiếp giải quyết nửa còn lại — khi method controller trả về, HandlerMethodReturnValueHandler xử lý return value thành HTTP response. Đối xứng hoàn toàn với resolver.
  • Bean Validation (bài 06 module 03): @Valid trên @RequestBody không liên quan resolver — nó trigger WebDataBinder.validate() sau khi resolver trả về object. Pitfall quên @Valid → constraint không chạy dù DTO có annotation đầy đủ.
  • Spring Security @AuthenticationPrincipal: resolver built-in của Spring Security — cùng pattern với custom @CurrentUser bài này trình bày, nhưng tích hợp sẵn auth context.

Tóm tắt

  • HandlerMethodArgumentResolver là interface SPI: supportsParameter() quyết định ai handle, resolveArgument() trả giá trị Java từ request.
  • Spring iterate danh sách resolver cho mỗi parameter; custom resolver thêm qua WebMvcConfigurer.addArgumentResolvers() được ưu tiên trước built-in.
  • Type conversion chạy qua ConversionService: String → primitive/Enum/UUID/Date. Enum mặc định case-sensitive — custom Converter<String, E> để fix.
  • @DateTimeFormat hướng dẫn formatter cho date; Boot 3 default ISO 8601 cho LocalDate/LocalDateTime.
  • Custom resolver (@CurrentUser) encapsulate logic một chỗ — type-safe, testable, không lặp.
  • Mass assignment: DTO chứa field nhạy cảm → split DTO theo authority; không dựa vào @JsonIgnore.

Tự kiểm tra

Tự kiểm tra
Q1
Khi Spring MVC gọi method create(@CurrentUser User user, @Valid @RequestBody OrderRequest req), nó resolve hai param theo thứ tự nào? Cơ chế bên dưới là gì?

Spring resolve từng param độc lập, theo thứ tự khai báo trong method signature. Với mỗi param, RequestMappingHandlerAdapter iterate danh sách HandlerMethodArgumentResolver từ đầu, gọi supportsParameter() cho đến khi tìm được resolver phù hợp, rồi gọi resolveArgument().

Param 1 — @CurrentUser User user: custom CurrentUserResolver (đứng đầu list) trả supportsParameter() = true vì param có annotation @CurrentUser. Resolver gọi SecurityContextHolder.getContext().getAuthentication().getPrincipal() và trả về User object.

Param 2 — @RequestBody OrderRequest req: RequestResponseBodyMethodProcessor xử lý. Nó đọc body qua HttpMessageConverter (Jackson), deserialize JSON → OrderRequest record, sau đó kích hoạt Bean Validation nếu có @Valid.

Hai param resolve hoàn toàn độc lập — không có phụ thuộc giữa chúng.

Q2
Tại sao @Retention(RetentionPolicy.RUNTIME) bắt buộc khi viết annotation custom cho ArgumentResolver? Bỏ annotation này thì sao?

Java annotation mặc định chỉ tồn tại đến compile time (RetentionPolicy.CLASS) — nghĩa là JVM không load vào heap lúc runtime. Reflection API (AnnotatedElement.getAnnotation(...)) sẽ trả về null cho mọi annotation không có RUNTIME retention.

Resolver gọi parameter.hasParameterAnnotation(CurrentUser.class) — đây là reflection call tại runtime. Nếu @CurrentUser bị compile away (mặc định CLASS), lời gọi này luôn trả false → resolver không bao giờ được chọn → param nhận null hoặc Spring throw NoHandlerFoundException.

Hệ quả: controller method với @CurrentUser User user luôn nhận null user mà không có lỗi compile — bug âm thầm rất khó debug. Bắt buộc dùng @Retention(RetentionPolicy.RUNTIME) cho mọi annotation dùng với reflection ở runtime.

Q3
App có endpoint GET /orders?status=active. Parameter khai báo @RequestParam OrderStatus status. Request trả 400. Nguyên nhân và 2 cách fix?

Nguyên nhân: StringToEnumConverter của Spring gọi OrderStatus.valueOf("active") — case-sensitive. OrderStatus có giá trị ACTIVE (uppercase), không có activeIllegalArgumentException → Spring wrap thành MethodArgumentTypeMismatchException → 400 Bad Request.

Fix 1 — Custom Converter (khuyến nghị):

@Component
public class OrderStatusConverter implements Converter<String, OrderStatus> {
  public OrderStatus convert(String source) {
      return OrderStatus.valueOf(source.toUpperCase());
  }
}

Spring Boot tự detect @Component implement Converter và đăng ký vào ConversionService. Active cho mọi endpoint dùng OrderStatus.

Fix 2 — Spring config property (Boot 2.7+):

spring.mvc.converters.preferred-json-mapper=jackson
# Hoac dung DeserializationFeature tren ObjectMapper:
spring.jackson.deserialization.read-enums-using-to-string=false

Cách này ít control hơn — ảnh hưởng toàn bộ enum deserialization, không chỉ riêng một enum. Custom Converter tốt hơn vì scoped.

Q4
Vì sao split DTO theo authority (UserPublicUpdate vs UserAdminUpdate) an toàn hơn dùng @JsonIgnore trên field nhạy cảm?

@JsonIgnore có hành vi phức tạp: nó ignore field khi serialize (object → JSON) nhưng behavior khi deserialize (JSON → object) phụ thuộc config. Mặc định Jackson MapperFeature.USE_ANNOTATIONS apply cả 2 chiều, nhưng nhiều config ghi đè điều này.

Các tình huống @JsonIgnore thất bại trên deserialization:

  • Field có @JsonProperty trên constructor parameter — override @JsonIgnore.
  • Custom ObjectMapper bật DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES=false — silent ignore, field vẫn bind nếu setter tồn tại.
  • Jackson update version thay đổi behavior — breaking change âm thầm.

Split DTO là tường vật lý: UserPublicUpdate không có field isAdmin, Jackson không thể bind dù client gửi. Không có cơ chế nào bypass được class definition. Đây là fail-safe by design — không phụ thuộc annotation behavior.

Rule ngắn: DTO nên chỉ chứa field client được phép gán — mọi field trong record đều là implicit setter với Jackson.

Q5
Custom ArgumentResolver trong addArgumentResolvers() được ưu tiên trước hay sau built-in resolver? Điều gì xảy ra nếu supportsParameter() của custom resolver trả true cho param @RequestBody OrderRequest req?

Custom resolver thêm qua WebMvcConfigurer.addArgumentResolvers() được đặt trước built-in resolver trong list. Spring iterate từ đầu — custom resolver được hỏi trước tiên.

Nếu custom resolver có supportsParameter() trả true cho param @RequestBody OrderRequest req, Spring dừng iteration và gọi resolveArgument() của custom resolver — bỏ qua hoàn toàn RequestResponseBodyMethodProcessor built-in. Hệ quả: body JSON không được đọc và deserialize, param nhận giá trị gì đó từ custom resolver (có thể null hoặc sai type) → NullPointerException hoặc ClassCastException tại runtime.

Fix: supportsParameter() phải kiểm tra điều kiện hẹp nhất có thể:

  • Kiểm tra annotation cụ thể: parameter.hasParameterAnnotation(CurrentUser.class).
  • Hoặc kiểm tra type cụ thể: parameter.getParameterType() == MySpecificType.class.
  • Không bao giờ viết return true vô điều kiện.

Lỗi này không có compile error hay warning — chỉ lộ ra khi chạy integration test đầy đủ. Đây là lý do cần test resolver với @WebMvcTest slice.

Bài tiếp theo: ResponseEntity & HTTP status codes

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên