TungDaDev's Blog

spring web bind annotations

Temp img.png
Published on
/18 mins read/

# giới thiệu

org.springframework.web.bind.annotation.* chứa các annotation xử lý HTTP request/response trong Spring MVC. Đây là foundation của mọi REST API trong Spring Boot.

# dependency (đã có trong starter-web)

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>

# controller annotations

# @Controller vs @RestController

// @Controller — trả về view name (Thymeleaf, JSP)
@Controller
@RequestMapping("/pages")
public class PageController {
 
   @GetMapping("/home")
   public String home(Model model) {
       model.addAttribute("title", "Home");
       return "home"; // → src/main/resources/templates/home.html
   }
 
   // Muốn trả JSON trong @Controller → thêm @ResponseBody
   @GetMapping("/api/data")
   @ResponseBody
   public Map<String, String> getData() {
       return Map.of("key", "value");
   }
}
 
// @RestController = @Controller + @ResponseBody (mọi method đều trả JSON)
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
 
   @GetMapping
   public List<ProductDTO> list() {
       return productService.findAll(); // Auto serialize to JSON
   }
}

# @RequestMapping — base mapping cho class/method

@RestController
@RequestMapping(
   path = "/api/v1/products",            // Base URL
   produces = MediaType.APPLICATION_JSON_VALUE,  // Response Content-Type
   consumes = MediaType.APPLICATION_JSON_VALUE   // Request Content-Type (chỉ cho POST/PUT)
)
public class ProductController { ... }

@RequestMapping attributes:

AttributeMô tảVí dụ
path/valueURL pattern"/api/v1/users"
methodHTTP methodRequestMethod.GET
producesResponse content type"application/json"
consumesRequest content type"application/json"
paramsRequired params"type=active"
headersRequired headers"X-API-Key"

# http method annotations

# @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
 
   private final OrderService orderService;
 
   // GET /api/v1/orders
   @GetMapping
   public ResponseEntity<APIResponse<Page<OrderDTO>>> list(Pageable pageable) {
       return ResponseEntity.ok(APIResponse.success(orderService.findAll(pageable)));
   }
 
   // GET /api/v1/orders/{id}
   @GetMapping("/{id}")
   public ResponseEntity<APIResponse<OrderDTO>> getById(@PathVariable UUID id) {
       return ResponseEntity.ok(APIResponse.success(orderService.getById(id)));
   }
 
   // GET /api/v1/orders/search?keyword=abc&status=PENDING
   @GetMapping("/search")
   public ResponseEntity<APIResponse<Page<OrderDTO>>> search(
           @RequestParam(required = false) String keyword,
           @RequestParam(required = false) OrderStatus status,
           Pageable pageable) {
       return ResponseEntity.ok(APIResponse.success(orderService.search(keyword, status, pageable)));
   }
 
   // POST /api/v1/orders
   @PostMapping
   @ResponseStatus(HttpStatus.CREATED)
   public ResponseEntity<APIResponse<OrderDTO>> create(
           @Valid @RequestBody CreateOrderRequest request) {
       OrderDTO created = orderService.create(request);
       URI location = URI.create("/api/v1/orders/" + created.getId());
       return ResponseEntity.created(location).body(APIResponse.success(created));
   }
 
   // PUT /api/v1/orders/{id} — Full update (replace toàn bộ)
   @PutMapping("/{id}")
   public ResponseEntity<APIResponse<OrderDTO>> update(
           @PathVariable UUID id,
           @Valid @RequestBody UpdateOrderRequest request) {
       return ResponseEntity.ok(APIResponse.success(orderService.update(id, request)));
   }
 
   // PATCH /api/v1/orders/{id} — Partial update (chỉ update fields gửi lên)
   @PatchMapping("/{id}")
   public ResponseEntity<APIResponse<OrderDTO>> patch(
           @PathVariable UUID id,
           @RequestBody Map<String, Object> updates) {
       return ResponseEntity.ok(APIResponse.success(orderService.patch(id, updates)));
   }
 
   // PATCH /api/v1/orders/{id}/status
   @PatchMapping("/{id}/status")
   public ResponseEntity<APIResponse<OrderDTO>> updateStatus(
           @PathVariable UUID id,
           @RequestParam OrderStatus status) {
       return ResponseEntity.ok(APIResponse.success(orderService.updateStatus(id, status)));
   }
 
   // DELETE /api/v1/orders/{id}
   @DeleteMapping("/{id}")
   @ResponseStatus(HttpStatus.NO_CONTENT)
   public void delete(@PathVariable UUID id) {
       orderService.delete(id);
   }
}

# mapping nâng cao

// Multiple paths
@GetMapping({"/", "/list", "/all"})
public List<OrderDTO> listAll() { ... }
 
// Conditional mapping by params
@GetMapping(params = "type=draft")      // GET /orders?type=draft
public List<OrderDTO> getDrafts() { ... }
 
@GetMapping(params = "type=completed")  // GET /orders?type=completed
public List<OrderDTO> getCompleted() { ... }
 
// Conditional mapping by headers
@GetMapping(headers = "X-API-Version=2")
public List<OrderDTOv2> listV2() { ... }
 
// Conditional by Accept header
@GetMapping(produces = "application/xml")
public List<OrderDTO> listXml() { ... }
 
@GetMapping(produces = "application/json")
public List<OrderDTO> listJson() { ... }

# request parameter annotations

# @PathVariable — url path segment

// Cơ bản
@GetMapping("/{id}")
public OrderDTO getById(@PathVariable UUID id) { ... }
 
// Tên khác với parameter name
@GetMapping("/{order-id}")
public OrderDTO getById(@PathVariable("order-id") UUID orderId) { ... }
 
// Multiple path variables
@GetMapping("/{orderId}/items/{itemId}")
public OrderItemDTO getItem(
       @PathVariable UUID orderId,
       @PathVariable UUID itemId) { ... }
 
// Optional path variable
@GetMapping({"/orders", "/orders/{id}"})
public Object getOrders(@PathVariable(required = false) UUID id) {
   if (id != null) return orderService.getById(id);
   return orderService.findAll();
}
 
// Regex pattern
@GetMapping("/{id:\\d+}")  // Chỉ match số
public OrderDTO getByNumericId(@PathVariable Long id) { ... }
 
@GetMapping("/code/{code:[A-Z]{3}-\\d{4}}")  // Match pattern VD: ORD-0001
public OrderDTO getByCode(@PathVariable String code) { ... }

# @RequestParam — query parameters

// Cơ bản — required by default
@GetMapping("/search")
public Page<OrderDTO> search(@RequestParam String keyword) { ... }
// GET /search?keyword=laptop → OK
// GET /search → 400 Bad Request (missing required param)
 
// Optional với default value
@GetMapping
public Page<OrderDTO> list(
       @RequestParam(defaultValue = "0") int page,
       @RequestParam(defaultValue = "20") int size,
       @RequestParam(defaultValue = "createdAt") String sortBy,
       @RequestParam(defaultValue = "desc") String direction) { ... }
 
// Optional (nullable)
@GetMapping("/filter")
public List<OrderDTO> filter(
       @RequestParam(required = false) OrderStatus status,    // null nếu không gửi
       @RequestParam(required = false) UUID categoryId,
       @RequestParam(required = false) String keyword) { ... }
 
// Collection parameter
@GetMapping("/batch")
public List<OrderDTO> getByIds(@RequestParam List<UUID> ids) { ... }
// GET /batch?ids=uuid1&ids=uuid2&ids=uuid3
// hoặc GET /batch?ids=uuid1,uuid2,uuid3
 
// Map — nhận tất cả params
@GetMapping("/dynamic")
public List<OrderDTO> dynamicFilter(@RequestParam Map<String, String> allParams) {
   // allParams = {keyword=abc, status=PENDING, page=0}
   return orderService.dynamicFilter(allParams);
}
 
// Enum parameter — auto convert string to enum
@GetMapping("/by-status")
public List<OrderDTO> byStatus(@RequestParam OrderStatus status) { ... }
// GET /by-status?status=PENDING → OrderStatus.PENDING

# @RequestHeader — http headers

@GetMapping
public OrderDTO get(
       @RequestHeader("Authorization") String authHeader,
       @RequestHeader("X-Workspace-Id") UUID workspaceId,
       @RequestHeader(value = "X-Request-Id", required = false) String requestId,
       @RequestHeader(value = "Accept-Language", defaultValue = "vi") String lang) {
   // ...
}
 
// Nhận tất cả headers
@GetMapping
public void process(@RequestHeader HttpHeaders headers) {
   String auth = headers.getFirst("Authorization");
   List<MediaType> accepts = headers.getAccept();
}
 
// Map
@GetMapping
public void process(@RequestHeader Map<String, String> headers) { ... }

# @CookieValue — http cookies

@GetMapping
public UserDTO getCurrentUser(
       @CookieValue("session_id") String sessionId,
       @CookieValue(value = "preferences", required = false) String preferences) {
   return userService.getBySession(sessionId);
}

# @RequestBody — json body → java object

@PostMapping
public OrderDTO create(@Valid @RequestBody CreateOrderRequest request) {
   return orderService.create(request);
}
 
// Optional body
@PatchMapping("/{id}")
public OrderDTO patch(
       @PathVariable UUID id,
       @RequestBody(required = false) Map<String, Object> updates) {
   if (updates == null || updates.isEmpty()) return orderService.getById(id);
   return orderService.patch(id, updates);
}

# @RequestPart — multipart request (file upload + json)

@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<DocumentDTO>> upload(
       @RequestPart("file") MultipartFile file,
       @RequestPart("metadata") @Valid DocumentMetadataDTO metadata) {
   // file → binary data
   // metadata → JSON object (auto-deserialized)
   return ResponseEntity.ok(APIResponse.success(documentService.upload(file, metadata)));
}
 
// Multiple files
@PostMapping("/batch-upload")
public List<DocumentDTO> batchUpload(
       @RequestPart("files") List<MultipartFile> files,
       @RequestPart(value = "description", required = false) String description) {
   return files.stream()
       .map(file -> documentService.upload(file, description))
       .toList();
}

# @ModelAttribute — form data / query params → object

// Bind query params vào object
@GetMapping("/search")
public Page<OrderDTO> search(@ModelAttribute OrderSearchCriteria criteria, Pageable pageable) {
   // GET /search?keyword=abc&status=PENDING&minPrice=100
   // → OrderSearchCriteria{keyword="abc", status=PENDING, minPrice=100}
   return orderService.search(criteria, pageable);
}
 
@Data
public class OrderSearchCriteria {
   private String keyword;
   private OrderStatus status;
   private BigDecimal minPrice;
   private BigDecimal maxPrice;
   private LocalDate fromDate;
   private LocalDate toDate;
}
 
// Pre-populate model (chạy trước mọi @RequestMapping trong controller)
@ModelAttribute("currentUser")
public UserDTO populateCurrentUser(Authentication auth) {
   return userService.getByUsername(auth.getName());
}

# response annotations

# @ResponseStatus — set http status code

// Method level
@PostMapping
@ResponseStatus(HttpStatus.CREATED)  // 201
public OrderDTO create(@Valid @RequestBody CreateOrderRequest request) { ... }
 
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)  // 204
public void delete(@PathVariable UUID id) { ... }
 
@PostMapping("/validate")
@ResponseStatus(HttpStatus.OK)  // 200 (explicit)
public ValidationResult validate(@RequestBody ValidateRequest request) { ... }
 
// Exception class level
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
   public ResourceNotFoundException(String message) { super(message); }
}
 
@ResponseStatus(value = HttpStatus.CONFLICT, reason = "Resource already exists")
public class DuplicateResourceException extends RuntimeException { ... }

# @ResponseBody — serialize return value to response body

// Đã implicit trong @RestController, chỉ cần khi dùng @Controller
@Controller
public class HybridController {
 
   @GetMapping("/page")
   public String viewPage() { return "page"; }  // Returns view name
 
   @GetMapping("/api/data")
   @ResponseBody  // Returns JSON
   public DataDTO getData() { return new DataDTO(); }
}

# ResponseEntity — full control over response

@RestController
@RequestMapping("/api/v1/files")
public class FileController {
 
   // Custom status + headers + body
   @PostMapping("/upload")
   public ResponseEntity<APIResponse<FileDTO>> upload(@RequestPart MultipartFile file) {
       FileDTO result = fileService.upload(file);
       return ResponseEntity
           .status(HttpStatus.CREATED)
           .header("X-File-Id", result.getId().toString())
           .header("Location", "/api/v1/files/" + result.getId())
           .body(APIResponse.success(result));
   }
 
   // File download
   @GetMapping("/{id}/download")
   public ResponseEntity<Resource> download(@PathVariable UUID id) {
       FileDownload download = fileService.getFile(id);
       return ResponseEntity.ok()
           .contentType(MediaType.parseMediaType(download.getContentType()))
           .header(HttpHeaders.CONTENT_DISPOSITION,
               "attachment; filename=\"" + download.getFilename() + "\"")
           .contentLength(download.getSize())
           .body(download.getResource());
   }
 
   // Conditional response (ETag/304)
   @GetMapping("/{id}")
   public ResponseEntity<FileDTO> get(@PathVariable UUID id, WebRequest request) {
       FileDTO file = fileService.getById(id);
       String etag = "\"" + file.getVersion() + "\"";
 
       if (request.checkNotModified(etag)) {
           return null;  // 304 Not Modified (Spring handles)
       }
 
       return ResponseEntity.ok()
           .eTag(etag)
           .cacheControl(CacheControl.maxAge(Duration.ofHours(1)))
           .body(file);
   }
 
   // No content (void alternatives)
   @DeleteMapping("/{id}")
   public ResponseEntity<Void> delete(@PathVariable UUID id) {
       fileService.delete(id);
       return ResponseEntity.noContent().build();
   }
 
   // Accepted (async processing)
   @PostMapping("/process")
   public ResponseEntity<Void> processAsync(@RequestBody ProcessRequest request) {
       String jobId = jobService.submit(request);
       return ResponseEntity.accepted()
           .header("X-Job-Id", jobId)
           .header("Location", "/api/v1/jobs/" + jobId)
           .build();
   }
}

# exception handling annotations

# @ExceptionHandler — xử lý exception trong controller

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
 
   // Chỉ apply cho controller này
   @ExceptionHandler(ProductNotFoundException.class)
   @ResponseStatus(HttpStatus.NOT_FOUND)
   public APIResponse<Void> handleNotFound(ProductNotFoundException ex) {
       return APIResponse.error("PRD_001_404", ex.getMessage());
   }
 
   @ExceptionHandler(DuplicateProductException.class)
   public ResponseEntity<APIResponse<Void>> handleDuplicate(DuplicateProductException ex) {
       return ResponseEntity.status(HttpStatus.CONFLICT)
           .body(APIResponse.error("PRD_002_409", ex.getMessage()));
   }
}

# @RestControllerAdvice / @ControllerAdvice — global exception handling

@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionHandler {
 
   // Validation errors (Jakarta Bean Validation)
   @ExceptionHandler(MethodArgumentNotValidException.class)
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public APIResponse<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
       Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
           .collect(Collectors.toMap(
               FieldError::getField,
               fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
               (a, b) -> a  // merge duplicates
           ));
       return APIResponse.error("VALIDATION_ERROR", "Validation failed", errors);
   }
 
   // Constraint violation (path params, query params)
   @ExceptionHandler(ConstraintViolationException.class)
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public APIResponse<List<String>> handleConstraint(ConstraintViolationException ex) {
       List<String> errors = ex.getConstraintViolations().stream()
           .map(v -> v.getPropertyPath() + ": " + v.getMessage())
           .toList();
       return APIResponse.error("CONSTRAINT_ERROR", "Constraint violation", errors);
   }
 
   // Entity not found
   @ExceptionHandler(EntityNotFoundException.class)
   @ResponseStatus(HttpStatus.NOT_FOUND)
   public APIResponse<Void> handleNotFound(EntityNotFoundException ex) {
       return APIResponse.error("NOT_FOUND", ex.getMessage());
   }
 
   // Access denied
   @ExceptionHandler(AccessDeniedException.class)
   @ResponseStatus(HttpStatus.FORBIDDEN)
   public APIResponse<Void> handleForbidden(AccessDeniedException ex) {
       return APIResponse.error("FORBIDDEN", "Access denied");
   }
 
   // Bad request (type mismatch, missing params)
   @ExceptionHandler({
       MissingServletRequestParameterException.class,
       MethodArgumentTypeMismatchException.class,
       HttpMessageNotReadableException.class
   })
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public APIResponse<Void> handleBadRequest(Exception ex) {
       return APIResponse.error("BAD_REQUEST", ex.getMessage());
   }
 
   // Method not allowed (405)
   @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
   @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
   public APIResponse<Void> handleMethodNotAllowed(HttpRequestMethodNotSupportedException ex) {
       return APIResponse.error("METHOD_NOT_ALLOWED",
           "Method " + ex.getMethod() + " not supported. Use: " + ex.getSupportedHttpMethods());
   }
 
   // Unsupported media type (415)
   @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
   @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
   public APIResponse<Void> handleUnsupportedMedia(HttpMediaTypeNotSupportedException ex) {
       return APIResponse.error("UNSUPPORTED_MEDIA", ex.getMessage());
   }
 
   // Optimistic lock (409 Conflict)
   @ExceptionHandler(OptimisticLockException.class)
   @ResponseStatus(HttpStatus.CONFLICT)
   public APIResponse<Void> handleOptimisticLock(OptimisticLockException ex) {
       return APIResponse.error("CONFLICT", "Resource was modified by another user. Please retry.");
   }
 
   // Catch-all
   @ExceptionHandler(Exception.class)
   @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
   public APIResponse<Void> handleGeneral(Exception ex, HttpServletRequest request) {
       String traceId = request.getHeader("X-Trace-Id");
       log.error("[traceId={}] Unhandled exception at {}", traceId, request.getRequestURI(), ex);
       return APIResponse.error("INTERNAL_ERROR", "An unexpected error occurred");
   }
}

# @ControllerAdvice với scope hạn chế

// Chỉ apply cho package cụ thể
@RestControllerAdvice(basePackages = "vn.com.vpbank.internal.csp.product.controller")
public class ProductExceptionHandler { ... }
 
// Chỉ apply cho controllers có annotation cụ thể
@RestControllerAdvice(annotations = RestController.class)
public class RestExceptionHandler { ... }
 
// Chỉ apply cho classes cụ thể
@RestControllerAdvice(assignableTypes = {ProductController.class, OrderController.class})
public class CommerceExceptionHandler { ... }

# @CrossOrigin — CORS Configuration

// Method level
@GetMapping("/public-data")
@CrossOrigin(origins = "https://app.vpbank.com")
public List<DataDTO> getPublicData() { ... }
 
// Controller level
@RestController
@RequestMapping("/api/v1/products")
@CrossOrigin(
   origins = {"https://app.vpbank.com", "https://admin.vpbank.com"},
   methods = {RequestMethod.GET, RequestMethod.POST},
   allowedHeaders = {"Authorization", "Content-Type", "X-Workspace-Id"},
   exposedHeaders = {"X-Total-Count", "X-Page-Count"},
   allowCredentials = "true",
   maxAge = 3600  // preflight cache 1 hour
)
public class ProductController { ... }
 
// Global CORS (thường dùng cách này thay vì @CrossOrigin)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
 
   @Override
   public void addCorsMappings(CorsRegistry registry) {
       registry.addMapping("/api/**")
           .allowedOrigins("https://app.vpbank.com", "https://admin.vpbank.com")
           .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
           .allowedHeaders("*")
           .exposedHeaders("X-Total-Count")
           .allowCredentials(true)
           .maxAge(3600);
   }
}

# @InitBinder — custom data binding

@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
 
   // Áp dụng cho tất cả methods trong controller này
   @InitBinder
   public void initBinder(WebDataBinder binder) {
       // Custom date format cho @RequestParam
       SimpleDateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy");
       dateFormat.setLenient(false);
       binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, true));
 
       // Trim whitespace cho String params
       binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
 
       // Disallow binding certain fields (security)
       binder.setDisallowedFields("id", "createdAt", "createdBy");
   }
 
   @GetMapping
   public List<ReportDTO> getReports(
           @RequestParam Date fromDate,  // Sẽ parse "15/01/2024" thành Date
           @RequestParam Date toDate) { ... }
}
 
// Global InitBinder
@ControllerAdvice
public class GlobalBinderAdvice {
 
   @InitBinder
   public void initBinder(WebDataBinder binder) {
       binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
   }
}

# @RequestAttribute & @SessionAttribute

# @RequestAttribute — dữ liệu từ filter/interceptor

// Filter set attribute
@Component
public class WorkspaceFilter extends OncePerRequestFilter {
   @Override
   protected void doFilterInternal(HttpServletRequest request,
           HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
       String workspaceId = request.getHeader("X-Workspace-Id");
       request.setAttribute("workspaceId", UUID.fromString(workspaceId));
       chain.doFilter(request, response);
   }
}
 
// Controller nhận attribute
@GetMapping
public List<ProductDTO> list(@RequestAttribute UUID workspaceId) {
   return productService.findByWorkspace(workspaceId);
}
 
@GetMapping("/{id}")
public ProductDTO get(
       @PathVariable UUID id,
       @RequestAttribute(required = false) String currentUserId) { ... }

# @SessionAttributes — lưu data trong session (mvc forms)

@Controller
@SessionAttributes("wizard")  // Lưu "wizard" object trong session
@RequestMapping("/wizard")
public class WizardController {
 
   @ModelAttribute("wizard")
   public WizardForm createWizard() {
       return new WizardForm();
   }
 
   @PostMapping("/step1")
   public String step1(@ModelAttribute("wizard") WizardForm wizard,
                       @RequestParam String name) {
       wizard.setName(name);
       return "redirect:/wizard/step2";
   }
 
   @PostMapping("/step2")
   public String step2(@ModelAttribute("wizard") WizardForm wizard,
                       @RequestParam String email,
                       SessionStatus status) {
       wizard.setEmail(email);
       wizardService.complete(wizard);
       status.setComplete();  // Clear session attribute
       return "redirect:/wizard/done";
   }
}

# @MatrixVariable — url matrix parameters

// URL: /api/products/filter;color=red;size=L/sort;by=price;dir=asc
@GetMapping("/filter/{filter}/sort/{sort}")
public List<ProductDTO> filter(
       @MatrixVariable(pathVar = "filter") Map<String, String> filterParams,
       @MatrixVariable(pathVar = "sort") Map<String, String> sortParams) {
   // filterParams = {color=red, size=L}
   // sortParams = {by=price, dir=asc}
   return productService.filter(filterParams, sortParams);
}
 
// Specific matrix variable
// URL: /api/products;category=electronics;brand=samsung
@GetMapping("/{path}")
public List<ProductDTO> filter(
       @MatrixVariable String category,
       @MatrixVariable(required = false) String brand) { ... }

Cần enable trong config:

@Configuration
public class WebConfig implements WebMvcConfigurer {
   @Override
   public void configurePathMatch(PathMatchConfigurer configurer) {
       UrlPathHelper helper = new UrlPathHelper();
       helper.setRemoveSemicolonContent(false); // Enable matrix variables
       configurer.setUrlPathHelper(helper);
   }
}

# async & streaming

# async response

@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {
 
   // DeferredResult — long-polling
   @GetMapping("/{id}/status")
   public DeferredResult<ResponseEntity<ReportStatusDTO>> pollStatus(@PathVariable UUID id) {
       DeferredResult<ResponseEntity<ReportStatusDTO>> result = new DeferredResult<>(30000L);
 
       result.onTimeout(() ->
           result.setResult(ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).build()));
 
       reportService.onStatusChange(id, status ->
           result.setResult(ResponseEntity.ok(status)));
 
       return result;
   }
 
   // Callable — async processing trên thread khác
   @GetMapping("/heavy")
   public Callable<List<ReportDTO>> getHeavyReport() {
       return () -> {
           // Chạy trên async thread (không block servlet thread)
           return reportService.generateHeavyReport();
       };
   }
 
   // StreamingResponseBody — large file download
   @GetMapping("/{id}/export")
   public ResponseEntity<StreamingResponseBody> export(@PathVariable UUID id) {
       StreamingResponseBody stream = outputStream -> {
           reportService.streamReport(id, outputStream);
       };
       return ResponseEntity.ok()
           .contentType(MediaType.APPLICATION_OCTET_STREAM)
           .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.csv\"")
           .body(stream);
   }
 
   // SSE (Server-Sent Events)
   @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
   public Flux<ServerSentEvent<ProgressDTO>> streamProgress() {
       return progressService.getProgressStream()
           .map(progress -> ServerSentEvent.<ProgressDTO>builder()
               .id(String.valueOf(progress.getStep()))
               .event("progress")
               .data(progress)
               .build());
   }
}

# content negotiation

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
 
   // Trả JSON hoặc XML tùy Accept header
   @GetMapping(value = "/{id}", produces = {
       MediaType.APPLICATION_JSON_VALUE,
       MediaType.APPLICATION_XML_VALUE
   })
   public ProductDTO get(@PathVariable UUID id) {
       return productService.getById(id);
   }
 
   // Chỉ nhận JSON
   @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
   public ProductDTO create(@RequestBody CreateProductRequest request) { ... }
 
   // Chỉ nhận multipart
   @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
   public ImportResult importFile(@RequestPart MultipartFile file) { ... }
 
   // Trả text/plain
   @GetMapping(value = "/{id}/name", produces = MediaType.TEXT_PLAIN_VALUE)
   public String getName(@PathVariable UUID id) {
       return productService.getById(id).getName();
   }
}

# validation annotations (kết hợp jakarta bean validation)

@RestController
@RequestMapping("/api/v1/users")
@Validated  // Enable validation cho @RequestParam, @PathVariable
public class UserController {
 
   // @Valid trên @RequestBody → trigger validation
   @PostMapping
   public UserDTO create(@Valid @RequestBody CreateUserRequest request) { ... }
 
   // Validate path variable
   @GetMapping("/{id}")
   public UserDTO getById(
           @PathVariable @NotNull UUID id) { ... }
 
   // Validate request params
   @GetMapping("/search")
   public Page<UserDTO> search(
           @RequestParam @Size(min = 2, max = 100) String keyword,
           @RequestParam @Min(0) int page,
           @RequestParam @Min(1) @Max(100) int size) { ... }
}
 
// Request DTO với validation
@Data
public class CreateUserRequest {
 
   @NotBlank(message = "Username is required")
   @Size(min = 3, max = 50, message = "Username must be 3-50 characters")
   @Pattern(regexp = "^[a-zA-Z0-9._-]+$", message = "Username contains invalid characters")
   private String username;
 
   @NotBlank(message = "Email is required")
   @Email(message = "Invalid email format")
   private String email;
 
   @NotBlank(message = "Password is required")
   @Size(min = 8, max = 128, message = "Password must be 8-128 characters")
   private String password;
 
   @NotNull(message = "Role is required")
   private UserRole role;
 
   @Size(max = 500, message = "Bio max 500 characters")
   private String bio;
 
   @Valid  // Trigger validation cho nested object
   @NotNull
   private AddressDTO address;
 
   @Valid
   @Size(max = 5, message = "Max 5 phone numbers")
   private List<@NotBlank String> phoneNumbers;
}
 
@Data
public class AddressDTO {
   @NotBlank
   private String street;
 
   @NotBlank
   private String city;
 
   @Pattern(regexp = "^\\d{5,6}$", message = "Invalid zip code")
   private String zipCode;
}

# custom validation groups

// Groups
public interface OnCreate {}
public interface OnUpdate {}
 
@Data
public class ProductRequest {
 
   @Null(groups = OnCreate.class, message = "ID must be null on create")
   @NotNull(groups = OnUpdate.class, message = "ID required on update")
   private UUID id;
 
   @NotBlank(groups = {OnCreate.class, OnUpdate.class})
   private String name;
 
   @NotNull(groups = OnCreate.class)
   private BigDecimal price;
}
 
// Controller
@PostMapping
public ProductDTO create(@Validated(OnCreate.class) @RequestBody ProductRequest request) { ... }
 
@PutMapping("/{id}")
public ProductDTO update(@Validated(OnUpdate.class) @RequestBody ProductRequest request) { ... }

# patterns thực tế — full rest controller

@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Slf4j
@Validated
public class ProductController {
 
   private final ProductService productService;
 
   @GetMapping
   public ResponseEntity<APIResponse<Page<ProductDTO>>> list(
           @RequestParam(required = false) String keyword,
           @RequestParam(required = false) ProductStatus status,
           @RequestParam(required = false) UUID categoryId,
           @RequestParam(defaultValue = "0") @Min(0) int page,
           @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
           @RequestParam(defaultValue = "createdAt,desc") String sort,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
 
       Pageable pageable = buildPageable(page, size, sort);
       ProductSearchCriteria criteria = ProductSearchCriteria.builder()
           .keyword(keyword)
           .status(status)
           .categoryId(categoryId)
           .workspaceId(workspaceId)
           .build();
 
       Page<ProductDTO> result = productService.search(criteria, pageable);
       return ResponseEntity.ok(APIResponse.success(result));
   }
 
   @GetMapping("/{id}")
   public ResponseEntity<APIResponse<ProductDTO>> getById(
           @PathVariable UUID id,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       ProductDTO product = productService.getById(id, workspaceId);
       return ResponseEntity.ok(APIResponse.success(product));
   }
 
   @PostMapping
   public ResponseEntity<APIResponse<ProductDTO>> create(
           @Valid @RequestBody CreateProductRequest request,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       log.info("Creating product | workspaceId={} | code={}", workspaceId, request.getCode());
       ProductDTO created = productService.create(request, workspaceId);
       URI location = URI.create("/api/v1/products/" + created.getId());
       return ResponseEntity.created(location).body(APIResponse.success(created));
   }
 
   @PutMapping("/{id}")
   public ResponseEntity<APIResponse<ProductDTO>> update(
           @PathVariable UUID id,
           @Valid @RequestBody UpdateProductRequest request,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       log.info("Updating product | id={} | workspaceId={}", id, workspaceId);
       ProductDTO updated = productService.update(id, request, workspaceId);
       return ResponseEntity.ok(APIResponse.success(updated));
   }
 
   @PatchMapping("/{id}/status")
   public ResponseEntity<APIResponse<ProductDTO>> updateStatus(
           @PathVariable UUID id,
           @RequestParam @NotNull ProductStatus status,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       ProductDTO updated = productService.updateStatus(id, status, workspaceId);
       return ResponseEntity.ok(APIResponse.success(updated));
   }
 
   @DeleteMapping("/{id}")
   public ResponseEntity<Void> delete(
           @PathVariable UUID id,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       log.info("Deleting product | id={} | workspaceId={}", id, workspaceId);
       productService.delete(id, workspaceId);
       return ResponseEntity.noContent().build();
   }
 
   @PostMapping("/batch")
   public ResponseEntity<APIResponse<BatchResult>> batchCreate(
           @Valid @RequestBody @Size(min = 1, max = 100) List<CreateProductRequest> requests,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       BatchResult result = productService.batchCreate(requests, workspaceId);
       return ResponseEntity.ok(APIResponse.success(result));
   }
 
   @PostMapping("/{id}/images")
   public ResponseEntity<APIResponse<ProductDTO>> uploadImage(
           @PathVariable UUID id,
           @RequestPart("file") MultipartFile file,
           @RequestPart(value = "caption", required = false) String caption,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       ProductDTO updated = productService.addImage(id, file, caption, workspaceId);
       return ResponseEntity.ok(APIResponse.success(updated));
   }
 
   @GetMapping("/export")
   public ResponseEntity<StreamingResponseBody> export(
           @RequestParam(required = false) ProductStatus status,
           @RequestHeader("X-Workspace-Id") UUID workspaceId) {
       StreamingResponseBody stream = out -> productService.exportCsv(status, workspaceId, out);
       return ResponseEntity.ok()
           .contentType(MediaType.parseMediaType("text/csv"))
           .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"products.csv\"")
           .body(stream);
   }
 
   private Pageable buildPageable(int page, int size, String sort) {
       String[] parts = sort.split(",");
       String property = parts[0];
       Sort.Direction direction = parts.length > 1 && "asc".equalsIgnoreCase(parts[1])
           ? Sort.Direction.ASC : Sort.Direction.DESC;
       return PageRequest.of(page, size, Sort.by(direction, property));
   }
}

# annotation quick reference

AnnotationTargetMục đích
@RestControllerClassREST controller (JSON response)
@ControllerClassMVC controller (view response)
@RequestMappingClass/MethodBase URL mapping
@GetMappingMethodHTTP GET
@PostMappingMethodHTTP POST
@PutMappingMethodHTTP PUT
@PatchMappingMethodHTTP PATCH
@DeleteMappingMethodHTTP DELETE
@PathVariableParameterURL path segment
@RequestParamParameterQuery parameter
@RequestBodyParameterJSON body → object
@RequestHeaderParameterHTTP header value
@CookieValueParameterCookie value
@RequestPartParameterMultipart part
@ModelAttributeParameter/MethodForm data binding
@RequestAttributeParameterServlet request attribute
@SessionAttributeParameterSession attribute
@MatrixVariableParameterMatrix URI variable
@ResponseStatusMethod/ClassSet HTTP status
@ResponseBodyMethodReturn value → response body
@ExceptionHandlerMethodHandle specific exception
@RestControllerAdviceClassGlobal exception handler
@ControllerAdviceClassGlobal controller advice
@CrossOriginClass/MethodCORS configuration
@InitBinderMethodCustom data binder
@ValidatedClass/ParameterEnable method-level validation

# kết luận

Một số nguyên tắc khi dùng Spring Web Bind Annotations:

  1. @RestController cho API, @Controller cho MVC views — không mix
  2. @Valid trên @RequestBody luôn luôn — validate input sớm nhất có thể
  3. @Validated trên class khi cần validate @RequestParam, @PathVariable
  4. ResponseEntity khi cần custom status/headers — plain return khi đơn giản
  5. @RestControllerAdvice cho global error handling — consistent error format
  6. @PathVariable cho resource ID, @RequestParam cho filters/search — RESTful convention
  7. @RequestPart cho file upload thay vì @RequestParam(MultipartFile)
  8. Không expose entity trực tiếp — luôn dùng DTO (request DTO ≠ response DTO)

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.