Validating Path and Query Parameters

Put constraints straight on @PathVariable, @RequestParam and @RequestHeader parameters and let Spring MVC's built-in method validation return a 400.

Syntax@GetMapping("/{id}") View get(@PathVariable @Positive long id) @GetMapping List<View> search(@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size)

Path variables, query parameters and headers are simple values, not objects, so there is nothing to put @Valid on. Instead you annotate the parameter itself:

@GetMapping
List<ProductView> search(@RequestParam @Min(1) @Max(100) int size) { ... }

Spring MVC notices the constraint and validates the arguments before calling the method. A violation raises HandlerMethodValidationException, which maps to 400 Bad Request. No extra annotation on the controller is needed.

Do not add @Validated to the controller

Older tutorials put @Validated on the controller class. That turns on the other mechanism, a proxy around the bean, which throws jakarta.validation.ConstraintViolationException. Spring MVC has no default mapping for that exception, so an out-of-range page size becomes a 500 Internal Server Error and an alert in your monitoring. Leave the class annotation off and let the built-in validation run.

Spring Boot has a property for the proxy side, spring.validation.method.adapt-constraint-violations, which defaults to false. Set to true, the proxy wraps the violations in Spring's MethodValidationException, which carries the same per-parameter results as HandlerMethodValidationException. That makes error handling more uniform, but it does not change the status: Spring MVC still answers MethodValidationException with a 500, because it treats a failed proxy check as a server-side problem. It is not a substitute for removing @Validated from a controller.

Conversion happens first

Before validation, Spring converts the text to the parameter type. /api/products/abc for a long id fails conversion with MethodArgumentTypeMismatchException, which is also a 400 but a different exception with no constraint information. Handle both if you shape error responses yourself.

Many parameters: bind a record

Search endpoints often take five or six filters. Rather than a long parameter list, bind them to a record with no annotation on the parameter (or @ModelAttribute) and put @Valid on it. Spring fills the record from query parameters by name, validates it, and raises MethodArgumentNotValidException on failure, the same as a request body.

One exception for everything

Once any parameter of a method carries a constraint directly, Spring validates the whole method in one pass. Errors on a @Valid @RequestBody in that same method then arrive inside HandlerMethodValidationException, not MethodArgumentNotValidException. An error handler that only knows the second exception loses those field errors. Handle both, as the lesson on returning field errors shows.

Why bother

An unchecked size of one million turns a paged endpoint into a full table scan and a huge response. A negative page makes PageRequest.of throw IllegalArgumentException, or becomes a negative SQL offset in hand-written queries, and either way the client gets a 500. Bounding every paging and filter value is cheap protection for the database behind the API.

Example

Example · java
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

record ProductView(long id, String name) {
}

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping("/{id}")
    public ProductView get(@PathVariable @Positive long id) {
        return new ProductView(id, "Desk lamp");
    }

    @GetMapping
    public List<ProductView> search(
            @RequestParam @NotBlank @Size(max = 100) String q,
            @RequestParam(defaultValue = "0") @PositiveOrZero int page,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
            @RequestHeader(name = "X-Region", defaultValue = "AU")
            @Pattern(regexp = "[A-Z]{2}") String region) {
        return List.of(new ProductView(1L, q + " (" + region + ", page " + page + " of size " + size + ")"));
    }
}

When to use it

  • Cap the page size of a list endpoint so no client can request the whole table.
  • Reject a malformed region header before it reaches a pricing lookup.
  • Bind a search screen's half-dozen filters to one validated record.

More examples

Binding many query parameters to a record

A record parameter with no annotation is bound from query parameters by name through its constructor: /api/orders?status=PAID&size=50 fills status and size. The compact constructor supplies defaults, and validation runs on the finished object. Failures raise MethodArgumentNotValidException with field names such as size, so this shares the error format of request bodies.

Example · java
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

record OrderSearch(
        @Size(max = 100) String customer,
        @Pattern(regexp = "OPEN|PAID|SHIPPED|CANCELLED") String status,
        @PositiveOrZero BigDecimal minTotal,
        @PositiveOrZero Integer page,
        @Min(1) @Max(100) Integer size) {

    OrderSearch {
        // Defaults for parameters the client left out.
        page = page == null ? 0 : page;
        size = size == null ? 20 : size;
    }
}

@RestController
public class OrderSearchController {

    @GetMapping("/api/orders")
    public List<String> search(@Valid OrderSearch search) {
        return List.of("page " + search.page() + ", size " + search.size());
    }
}

Constraints on list elements in a query string

@Size(max = 50) limits how many ids one call can ask for, which bounds the size of the IN clause it turns into. @Positive inside the type argument checks each element, and the error carries the element's index, so the client learns which id was bad. A value like id=abc still fails earlier, in conversion.

Example · java
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class BatchLookupController {

    // GET /api/customers/batch?id=4&id=9&id=12
    @GetMapping("/api/customers/batch")
    public List<Long> lookup(
            @RequestParam("id") @NotEmpty @Size(max = 50) List<@Positive Long> ids) {
        return ids;
    }
}

Testing the 400 with MockMvcTester

@WebMvcTest starts only the web layer, including the validator, so these tests run in well under a second. The first test hits the constraint, the second hits conversion: both should be 400, and having both stops a later change, such as adding @Validated to the class, from quietly turning one of them into a 500.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
class PagedController {

    @GetMapping("/api/items")
    List<String> list(@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
        return List.of();
    }
}

@WebMvcTest(PagedController.class)
class PagedControllerTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void rejectsOversizedPage() {
        assertThat(mvc.get().uri("/api/items?size=5000"))
                .hasStatus(HttpStatus.BAD_REQUEST);
    }

    @Test
    void rejectsNonNumericPage() {
        assertThat(mvc.get().uri("/api/items?size=lots"))
                .hasStatus(HttpStatus.BAD_REQUEST);
    }

    @Test
    void acceptsDefault() {
        assertThat(mvc.get().uri("/api/items")).hasStatusOk();
    }
}

Discussion

  • Be the first to comment on this lesson.