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

  • @PathVariable binds URL segments; @RequestParam binds query string parameters
  • @RequestBody deserializes the request JSON body into a Java object
  • @RequestHeader and @CookieValue extract headers and cookies
  • Spring MVC injects many useful types automatically (Principal, Locale, HttpServletRequest)
  • MultipartFile handles file uploads; configure size limits in application.yml
  • Custom HandlerMethodArgumentResolver lets you inject any type into controller methods
  • Register Converter beans 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.