Handling Requests: Path Variables, Query Params, and Request Bodies
A REST API receives data in many places: URL path, query string, body, headers, cookies. This article covers every way to extract that data in Spring MVC.
@PathVariable — Extract from URL
Use @PathVariable when the data is part of the URL path:
// URL: GET /api/orders/550e8400-e29b-41d4-a716-446655440000
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable UUID id) {
return orderService.findById(id)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
Spring automatically converts the string segment to the parameter type (UUID, Long, int, etc.).
Multiple path variables
// URL: GET /api/customers/abc/orders/123
@GetMapping("/api/customers/{customerId}/orders/{orderId}")
public OrderResponse getCustomerOrder(
@PathVariable UUID customerId,
@PathVariable UUID orderId) {
return orderService.findByCustomerAndId(customerId, orderId)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
Optional path variable
// Matches /api/orders and /api/orders/abc
@GetMapping({"/api/orders", "/api/orders/{id}"})
public Object ordersOrOne(@PathVariable(required = false) UUID id) {
if (id == null) return orderService.findAll();
return orderService.findById(id).orElseThrow();
}
In practice, use separate methods for clarity rather than optional path variables.
@RequestParam — Query String Parameters
Use @RequestParam for filtering, pagination, and optional inputs from the query string:
// URL: GET /api/orders?status=PENDING&page=0&size=20&sort=createdAt
@GetMapping("/api/orders")
public Page<OrderResponse> listOrders(
@RequestParam(required = false) OrderStatus status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sort) {
return orderService.findAll(status, page, size, sort)
.map(OrderResponse::from);
}
Multi-value parameters
// URL: /api/orders?status=PENDING&status=CONFIRMED
@GetMapping("/api/orders")
public List<OrderResponse> listByStatuses(
@RequestParam List<OrderStatus> status) {
return orderService.findByStatuses(status)
.stream().map(OrderResponse::from).toList();
}
Map of parameters
When you don’t know the parameter names up front:
@GetMapping("/api/search")
public SearchResults search(@RequestParam Map<String, String> params) {
// params contains all query parameters as key-value pairs
return searchService.search(params);
}
@RequestBody — Deserialize the Request Body
Use @RequestBody to read JSON (or XML) from the request body:
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity
.created(URI.create("/api/orders/" + order.id()))
.body(OrderResponse.from(order));
}
Jackson deserializes the JSON to CreateOrderRequest. @Valid triggers Bean Validation (covered in the next article).
Handling complex nested bodies
public record CreateOrderRequest(
@NotNull UUID customerId,
@NotEmpty List<@Valid OrderItemRequest> items,
@Valid ShippingAddress shippingAddress,
String promoCode // optional — null if not provided
) {}
public record OrderItemRequest(
@NotNull UUID productId,
@Positive int quantity,
@Positive BigDecimal unitPrice
) {}
public record ShippingAddress(
@NotBlank String line1,
String line2,
@NotBlank String city,
@NotBlank String country,
@NotBlank String postalCode
) {}
Partial updates with @RequestBody
For PATCH endpoints, use a nullable record or a map:
// Approach 1: Nullable record fields (all fields optional)
public record UpdateOrderRequest(
OrderStatus status, // null = don't update
ShippingAddress address // null = don't update
) {}
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> updateOrder(
@PathVariable UUID id,
@RequestBody UpdateOrderRequest request) {
return orderService.update(id, request)
.map(o -> ResponseEntity.ok(OrderResponse.from(o)))
.orElse(ResponseEntity.notFound().build());
}
// Approach 2: Map for fully dynamic partial updates
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> patchOrder(
@PathVariable UUID id,
@RequestBody Map<String, Object> fields) {
return orderService.patch(id, fields)
.map(o -> ResponseEntity.ok(OrderResponse.from(o)))
.orElse(ResponseEntity.notFound().build());
}
@RequestHeader — Read HTTP Headers
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestBody CreateOrderRequest request,
@RequestHeader("X-Idempotency-Key") String idempotencyKey,
@RequestHeader(value = "X-User-Id", required = false) UUID userId) {
return orderService.createIdempotent(request, idempotencyKey, userId);
}
For standard headers, Spring has built-in support:
@GetMapping("/api/orders/{id}")
public ResponseEntity<OrderResponse> getOrder(
@PathVariable UUID id,
@RequestHeader(HttpHeaders.IF_NONE_MATCH) String etag) {
Order order = orderService.findById(id).orElseThrow();
String currentEtag = "\"" + order.version() + "\"";
if (currentEtag.equals(etag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.eTag(currentEtag)
.body(OrderResponse.from(order));
}
@CookieValue — Read Cookies
@GetMapping("/api/cart")
public CartResponse getCart(
@CookieValue(value = "sessionId", required = false) String sessionId) {
if (sessionId == null) return CartResponse.empty();
return cartService.findBySession(sessionId);
}
HttpServletRequest — Raw Access
When you need full access to the raw request:
@PostMapping("/api/webhook")
public ResponseEntity<Void> handleWebhook(
HttpServletRequest request,
@RequestBody byte[] rawBody) {
String signature = request.getHeader("X-Signature-SHA256");
String remoteAddr = request.getRemoteAddr();
webhookService.verify(rawBody, signature, remoteAddr);
webhookService.process(rawBody);
return ResponseEntity.ok().build();
}
Method Argument Injection
Spring MVC injects many types automatically into controller methods:
@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestBody CreateOrderRequest request,
Principal principal, // authenticated user
Locale locale, // request locale (from Accept-Language)
TimeZone timeZone, // client timezone
HttpServletRequest req, // raw request
HttpServletResponse res, // raw response
BindingResult errors, // validation errors (when paired with @Valid)
UriComponentsBuilder ucb) { // for building response URLs
// All injected automatically by Spring MVC
}
Multipart File Uploads
@PostMapping("/api/orders/{id}/attachments")
public ResponseEntity<AttachmentResponse> uploadAttachment(
@PathVariable UUID id,
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String description) {
if (file.isEmpty()) {
throw new BadRequestException("File must not be empty");
}
String contentType = file.getContentType();
if (!Set.of("image/png", "image/jpeg", "application/pdf").contains(contentType)) {
throw new BadRequestException("Only PNG, JPEG, and PDF allowed");
}
Attachment attachment = attachmentService.save(id, file, description);
return ResponseEntity
.created(URI.create("/api/orders/" + id + "/attachments/" + attachment.id()))
.body(AttachmentResponse.from(attachment));
}
Configure upload limits:
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 25MB
Custom Argument Resolvers
When you want to inject something custom into every controller method — like the authenticated user from a JWT:
// Custom annotation
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}
// Resolver
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& parameter.getParameterType().equals(UserPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
return (UserPrincipal) request.getAttribute("currentUser");
}
}
// Register it
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver resolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(resolver);
}
}
// Use it
@GetMapping("/api/orders")
public List<OrderResponse> myOrders(@CurrentUser UserPrincipal user) {
return orderService.findByCustomer(user.id())
.stream().map(OrderResponse::from).toList();
}
Type Conversion
Spring automatically converts strings to typed parameters. You can add converters for custom types:
// Convert "2024-01-15" string to LocalDate automatically
@GetMapping("/api/orders")
public List<OrderResponse> ordersAfter(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since) {
return orderService.findAfter(since).stream().map(OrderResponse::from).toList();
}
For custom types, register a Converter:
@Component
public class OrderStatusConverter implements Converter<String, OrderStatus> {
@Override
public OrderStatus convert(String source) {
return OrderStatus.valueOf(source.toUpperCase());
}
}
Now @RequestParam OrderStatus status accepts "pending", "PENDING", "Pending" — all work.
Complete Example: A Realistic List Endpoint
@GetMapping("/api/orders")
public ResponseEntity<PagedResponse<OrderResponse>> listOrders(
@RequestParam(required = false) UUID customerId,
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "createdAt,desc") String sort,
@RequestHeader(value = "X-Request-ID", required = false) String requestId) {
OrderFilter filter = OrderFilter.builder()
.customerId(customerId)
.status(status)
.from(from)
.to(to)
.build();
Page<Order> page_result = orderService.findAll(filter, PageRequest.of(page, size));
return ResponseEntity.ok()
.header("X-Request-ID", requestId)
.body(PagedResponse.from(page_result, OrderResponse::from));
}
What You’ve Learned
@PathVariablebinds URL segments;@RequestParambinds query string parameters@RequestBodydeserializes the request JSON body into a Java object@RequestHeaderand@CookieValueextract headers and cookies- Spring MVC injects many useful types automatically (
Principal,Locale,HttpServletRequest) MultipartFilehandles file uploads; configure size limits inapplication.yml- Custom
HandlerMethodArgumentResolverlets you inject any type into controller methods - Register
Converterbeans for automatic type coercion in path variables and query params
Next: Article 10 — DTOs and Response Shaping — why you shouldn’t expose entities directly and how to design clean API responses.