Request Parameters
Read query string values with @RequestParam, give them defaults, accept lists, and bind a whole set of filters to a record.
@GetMapping("/api/products")
List<Product> search(@RequestParam String q,
@RequestParam(defaultValue = "20") int size,
@RequestParam Optional<String> category) { ... }Request parameters are the name=value pairs after the ? in a URL, and also the fields of an HTML form posted as application/x-www-form-urlencoded. In a JSON API they carry options: search terms, filters, page numbers, sort order. @RequestParam binds one parameter to a method argument and converts it like a path variable does.
Required, optional and defaults
A @RequestParam is required unless you say otherwise. A request without it gets 400 with MissingServletRequestParameterException, which is what you want for a parameter the endpoint cannot work without. For optional values you have three choices:
defaultValue = "20": the argument is never null andrequiredbecomes false automatically. Best for page sizes and sort orders.Optional<String>: makes absence explicit in the type.required = falsewith a wrapper type likeInteger: the value is null when missing. Never use a primitive here; Spring cannot put null into anint, throws anIllegalStateExceptionand the client gets a 500 for what is really its own omission.
An empty value, as in ?status=, counts as present. With a default, Spring uses the default; without one, a String receives the empty string, while a number has nothing to convert and a required parameter then fails with 400.
Many values and many parameters
Declare a List<String> and Spring collects repeated parameters (?tag=a&tag=b) and also splits comma-separated ones (?tag=a,b). When a search has several filters, bind them to a record instead of listing six @RequestParam arguments. A parameter with no annotation whose type is not a simple value is bound from request parameters by name, and records are built through their constructor.
record ProductFilter(String category, Integer maxPrice, List<String> tag) {}
@GetMapping
List<Product> search(ProductFilter filter) { ... }
Production concerns
- Cap sizes.
?size=1000000is a cheap way to make your database work hard. Clamp it in code or with validation. - Whitelist sort fields. Passing a client's
sortstraight into a query string is an injection risk and lets clients sort on unindexed columns. - Query strings end up in access logs, proxy logs and browser history. Never accept tokens, passwords or personal data as query parameters.
Example
import java.util.List;
import java.util.Optional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
record Product(long id, String name, String category, int pricePence) {}
record ProductPage(List<Product> items, int page, int size) {}
public class ProductSearchController {
private static final int MAX_SIZE = 100;
private final List<Product> catalogue = List.of(
new Product(1, "Blue kettle", "kitchen", 2999),
new Product(2, "Desk lamp", "office", 1999));
// GET /api/products?q=kettle&category=kitchen&page=0&size=20
("/api/products")
public ProductPage search( String q,
Optional<String> category,
(defaultValue = "0") int page,
(defaultValue = "20") int size) {
int safeSize = Math.clamp(size, 1, MAX_SIZE);
List<Product> matches = catalogue.stream()
.filter(p -> p.name().toLowerCase().contains(q.toLowerCase()))
.filter(p -> category.map(c -> c.equals(p.category())).orElse(true))
.skip((long) Math.max(page, 0) * safeSize)
.limit(safeSize)
.toList();
return new ProductPage(matches, page, safeSize);
}
}When to use it
- Accept ?q=kettle&page=2&size=20 on a product search and clamp size so no client can request a million rows.
- Filter articles by several tags sent as ?tag=java&tag=spring or ?tag=java,spring.
- Bind a rental search form with six optional filters to one record that is passed straight to the service.
More examples
Repeated and comma-separated values
Both URL styles end up as the same <code>List</code>, so clients can use whichever their HTTP library produces. The parameter is optional here and arrives as null when absent, which the method treats as "no filter". Use the singular name in the URL (<code>tag</code>) and the plural in Java; <code>name</code> connects the two.
import java.util.List;
import java.util.Set;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
record Article(String title, Set<String> tags) {}
@RestController
public class ArticleController {
private final List<Article> articles = List.of(
new Article("Records in Java", Set.of("java", "records")),
new Article("Spring MVC basics", Set.of("spring", "web")));
// GET /api/articles?tag=java&tag=spring or GET /api/articles?tag=java,spring
@GetMapping("/api/articles")
public List<Article> byTags(@RequestParam(name = "tag", required = false) List<String> tags) {
if (tags == null || tags.isEmpty()) {
return articles;
}
return articles.stream()
.filter(a -> a.tags().stream().anyMatch(tags::contains))
.toList();
}
}Binding a group of filters to a record
The record has no annotation, so Spring treats it as a model attribute and fills it from request parameters by name through the canonical constructor. Adding a filter is now one record component, not another method argument, and the same record can be passed to a service or repository. Use wrapper types so a missing parameter means null; a bad number such as <code>maxRent=cheap</code> still produces 400.
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
record Listing(long id, String city, int bedrooms, int rentPerMonth) {}
// Bound from ?city=Leeds&minBedrooms=2&maxRent=1200; missing values are null.
record ListingFilter(String city, Integer minBedrooms, Integer maxRent) {}
@RestController
public class ListingController {
private final List<Listing> listings = List.of(
new Listing(1, "Leeds", 2, 1100),
new Listing(2, "York", 3, 1450));
@GetMapping("/api/listings")
public List<Listing> search(ListingFilter filter) {
return listings.stream()
.filter(l -> filter.city() == null || l.city().equalsIgnoreCase(filter.city()))
.filter(l -> filter.minBedrooms() == null || l.bedrooms() >= filter.minBedrooms())
.filter(l -> filter.maxRent() == null || l.rentPerMonth() <= filter.maxRent())
.toList();
}
}Proving a missing parameter is a 400
A slice test with <code>@WebMvcTest</code> starts only the web layer, so it runs in well under a second. In Spring Boot 4 the annotation lives in <code>org.springframework.boot.webmvc.test.autoconfigure</code> and comes from the <code>spring-boot-starter-webmvc-test</code> starter. Pinning the 400 and the default value in a test stops a later refactor from quietly making the search parameter optional.
import static org.assertj.core.api.Assertions.assertThat;
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 SearchController {
@GetMapping("/api/search")
List<String> search(@RequestParam String q, @RequestParam(defaultValue = "10") int size) {
return List.of(q + " (" + size + ")");
}
}
@WebMvcTest(SearchController.class)
public class SearchControllerTest {
@Autowired
MockMvcTester mvc;
@Test
void missingRequiredParameterIsBadRequest() {
assertThat(mvc.get().uri("/api/search")).hasStatus(HttpStatus.BAD_REQUEST);
}
@Test
void defaultIsUsedWhenSizeIsAbsent() {
assertThat(mvc.get().uri("/api/search").param("q", "kettle"))
.hasStatusOk()
.bodyJson().extractingPath("$[0]").isEqualTo("kettle (10)");
}
}
Discussion