CodeGym /Courses /Spring REST & MVC /Validation page, size, sort and taskId

Validation page, size, sort and taskId

Spring REST & MVC
Level 16, Lesson 2
Available

1. Validating query and path parameters

When we first start writing a REST API, there is a temptation to think that the real “input” is @RequestBody, and everything else is somehow secondary: after all, what about ?page= or {taskId} in the path? In practice, it is the opposite: list-endpoints and resource-endpoints live on query/path parameters all the time, and these are what most often break scenarios before JSON even appears.

Imagine a client calls GET /api/v1/tasks?page=-1. From a human point of view, it looks like “I want page minus one” — that is, like asking to open a book to a page that does not exist. Or a request with size=100000: the client is basically asking the server to “bring me all the tasks in the world, preferably at once, and coffee while you are at it.” Even if we currently have an in-memory store, this is still contractually invalid input, and it should not get through.

The same applies to taskId in GET /api/v1/tasks/{taskId}. In the project, identifiers are UUID strings. If the client sends taskId=123, that is not “just not found.” That is not the identifier format we work with at all. Which means this is the “bad input” category of error, and it is better to catch it at the boundary, rather than in the service, where the circus of parsing and unclear exceptions begins.

One simple idea is important to keep in mind: query and path parameters are part of the external API contract. If the contract says that page cannot be negative and size cannot be greater than 100, then that should be expressed right where the parameter is declared. That is, in the controller method signature.

2. Binding and validation: two gates

It is very useful to understand that before Spring even calls your controller method, the request passes through at least two layers of “checks.” The first layer is binding input strings to Java types (binding + type conversion). The second layer is Bean Validation constraints, which you write with annotations (@Min, @Max, @Pattern, etc.). These two layers are similar in effect (in both cases you get 400), but they represent different problems.

If the request comes in as ?page=abc, Spring cannot turn the string "abc" into an int. That is a format/type error: the parameter cannot be converted. Your controller method does not even start, because the arguments could not be assembled.

If the request comes in as ?page=-1, then "-1" converts perfectly well to an int, but the @Min(0) constraint says: “the value is outside the allowed range.” That is no longer a conversion error, but a contract violation.

It is convenient to think of it schematically like this:

flowchart LR
    %% First comes binding and type conversion
    A[HTTP request] --> B[Binding + type conversion]
    %% Then Bean Validation constraints run on the parameters/DTO
    B -->|success| C[Bean Validation constraints]
    C -->|success| D[Controller method]
    B -->|format error| E[HTTP 400]
    C -->|constraint violated| E

The practical takeaway from this diagram is simple: if you keep parameters as String and parse them manually in the controller, you are effectively turning off the first gate and putting it on your own shoulders. And if you do not add constraints (@Min, @Max, @Pattern), then the second gate has nothing to stand on: formally valid values (-1, 999999) will happily slip into the application.

To avoid turning the controller into a mini-parser and mini-validator at the same time, it is better to let Spring do its job: it already knows how to convert types and it already knows how to run Bean Validation — you just need to describe the constraints correctly.

3. Validating controller parameters: not the same mechanism as @Valid

At this point, many beginners make the classic mistake: “I already put @Valid on @RequestBody, so validation is enabled for the entire method.” Not quite. @Valid is responsible for validating the object (DTO) and for cascading validation into nested data. But simple method parameters (int page, String sort, String taskId) reach the controller by a different route: first they are converted from strings, and only then are they checked as part of the method signature.

In older examples, you will often see @Validated on the controller. It is useful to know this annotation, but in the current MVC line you should not treat it as a mandatory switch for basic controller snippets. What matters here is a different distinction: @Valid is about the object graph, while @Min / @Max / @Pattern on @RequestParam and @PathVariable are about the contract of simple method parameters.

In the Task Tracker API project, controllers live in com.example.tasktracker.api.controller. The minimal TaskController skeleton is the usual one:

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // Standard Spring MVC REST controller
@RequestMapping("/api/v1/tasks") // Base path for all controller endpoints
public class TaskController {
}

From there, the constraints live directly on method parameters, and Spring checks them before entering the method. And @Valid is still useful for object arguments such as @RequestBody and @ModelAttribute.

4. Validating page and size

Pagination parameters are among the most heavily used inputs in an API. They are called constantly, they are passed by hand, they are passed by the UI, they are embedded in links. And if you do not limit them at the boundary, you will start seeing very strange effects: empty pages “from the past,” negative indices, gigantic response sizes, and, in the end, unhappy users (or your browser itself being unhappy).

While a list-endpoint has only a few parameters, it is useful to keep them directly in the method signature. That makes it easier to see in isolation where @Min, @Max, and defaultValue take effect. But this is only an intermediate shape: as soon as filters are added to page, size, and sort, such a method is already asking to be moved into a criteria DTO.

In our Task Tracker API contract, the parameters are as follows: page is zero-based, default 0; size defaults to 20, with a maximum of 100. This means the minimally reasonable constraints look like this: page >= 0, 1 <= size <= 100. This is exactly what we will express with @Min and @Max.

Here is an example of a list-endpoint where the constraints sit directly in the method signature:

package com.example.tasktracker.api.controller;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public String list(
            @RequestParam(defaultValue = "0") @Min(0) int page, // page: only 0 and above
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size // size: 1..100, otherwise 400
    ) {
        return "ok";
    }
}

Notice two things. First, we accept page and size as int, not as String. This allows Spring to handle conversion, and it saves us from writing a manual Integer.parseInt(...) and catching NumberFormatException in the controller. Second, defaultValue is a string, but it must be valid for the type and satisfy the constraints. If you accidentally set defaultValue = "-5" together with @Min(0), you are literally creating an endpoint that contradicts itself.

A good habit is to treat defaultValue as part of the contract. It is not a “technical detail,” it is public behavior of your API. The client may omit the parameter — and then the server decides on its behalf. That decision should therefore be valid.

If you want optional parameters without defaultValue and use Integer, it is important to remember that most constraints (for example, @Min) consider null a valid value by default. That is, @Min(0) on Integer page does not “forbid missing page.” It simply says: “if page exists, let it be >= 0.” That is fine, but then the code needs to handle null carefully (or use @NotNull), otherwise you will get an NPE where you did not want to play detective at all.

5. Validating sort and taskId

Validating sort: whitelist through @Pattern

Sorting is a parameter that looks simple on the surface: a string like updatedAt,desc. But sorting is exactly what often turns an API into a “hidden contract” if you do not agree on the format and allowed values. The client sends sort=priority,sideways, you think “well, I will try,” then someone adds a new field to the model, someone else changes a name, and suddenly sorting starts producing strange results or crashing.

In our project, we agreed on the field,dir format, where dir is asc or desc, and field belongs to the whitelist: createdAt, updatedAt, dueDate, priority, status, title. This agreement can be expressed directly in the contract through @Pattern.

A minimal example looks like this:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public String listBySort(
        @RequestParam(defaultValue = "updatedAt,desc")
        // We fix the contract: only the listed fields and only asc/desc
        @Pattern(regexp = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$")
        String sort
) {
    return "ok";
}

The regular expression here is not about “regex magic,” but about the whitelist. It says: “sorting is allowed only by these fields and only in these directions.” That immediately makes the contract predictable. It is important that we use ^ and $ so the string matches in full, rather than having “the right symbols somewhere inside.” In other words, updatedAt,descPLEASE will not pass, and that is good.

Yes, regular expressions can be hated, and you have moral rights to do so. But here regex is not an attempt to solve a world-scale problem; it is a simple way to fix the format and whitelist in one place. The alternative is to parse the string by hand, write if/else, invent error messages, and once again turn the controller into a mini-parser. And we have agreed that our controllers are thin and do not like unnecessary drama.

As long as sort lives as a separate @RequestParam, the whitelist is convenient to keep right on the parameter. If the same search contract is collected into one criteria object, the rule does not go anywhere — it just moves to the sort field inside it.

Validating taskId: @Pattern for UUID

The taskId parameter in the path (/api/v1/tasks/{taskId}) is the address of a specific resource. In our project architecture, the identifier is a UUID string. That means a “valid taskId” is not just any string, but a string of a specific format. And if the client sent “not a UUID,” that is not a “task not found” scenario. It is a “you formed the request incorrectly” scenario.

The most straightforward way to fix this is to put @Pattern directly on @PathVariable. For an educational project, a simple check like “36 characters and hyphens” is enough to filter out obvious junk:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/api/v1/tasks/{taskId}")
public String details(
        @PathVariable
        // Lightweight format check: filter out values that are clearly not similar enough
        @Pattern(regexp = "^[0-9a-fA-F-]{36}$")
        String taskId
) {
    return "ok";
}

Yes, this expression does not check every nuance of UUIDs (for example, the positions of the hyphens), but it does show the principle well: a path parameter can and should be validated, and this is done with the same constraints as for the request body.

If you want a stricter check, you can use a more precise regular expression or even accept the parameter as UUID so that Spring performs type conversion. But the key idea for this lecture is not “perfect UUID validation,” but rather that a resource identifier is also input. And if it does not match our contract, the request should not reach the service layer.

An additional nice bonus: when taskId is validated at the boundary, the service can work with it as a “normal” identifier, without constantly worrying about the possibility of a format like “I am not a UUID at all; I came here to ruin your mood.”

6. Example TaskController with parameters

Now let us put together a general fragment that is as close as possible to our project. For now, to understand the mechanics, we will keep the parameters separate: that makes it easier to see how page, size, sort, and taskId live on the same method. But as soon as filters start accumulating on GET /api/v1/tasks, the same search contract is more convenient to fold into TaskSearchCriteria.

First, let us show the controller class and constants for regular expressions so we do not keep a “regex carpet” right inside the parameters:

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    // Simplified UUID check (educational version): "looks like a UUID by length/characters"
    static final String UUID_LITE = "^[0-9a-fA-F-]{36}$";

    // sort format whitelist: field,dir — only allowed fields and directions
    static final String SORT = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$";
}

Now the list method with page, size, sort:

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping
public String list(
        @RequestParam(defaultValue = "0") @Min(0) int page, // page: pages start at 0
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, // size: limit the maximum response size
        @RequestParam(defaultValue = "updatedAt,desc") @Pattern(regexp = SORT) String sort // sort: whitelist only
) {
    return "ok";
}

And the detail method with taskId:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/{taskId}")
public String details(
        @PathVariable @Pattern(regexp = UUID_LITE) String taskId // taskId: filter junk at the API boundary
) {
    return "ok";
}

If you now call the endpoints with “bad” parameters, you will see that the method simply does not execute. You can verify this even without pretty error formatting: put a breakpoint in the method — and it will not fire. Spring cuts the request off earlier because the input violated the contract.

For self-checking, it is handy to have a couple of requests in a .http file:

### A set of requests for manual verification: each of them should result in 400
### (which means the controller method must not execute)

### page < 0 -> 400
GET http://localhost:8080/api/v1/tasks?page=-1
Accept: application/json

### size too big -> 400
GET http://localhost:8080/api/v1/tasks?size=1000
Accept: application/json

### sort not in whitelist -> 400
GET http://localhost:8080/api/v1/tasks?sort=unknownField,asc
Accept: application/json

### taskId not UUID-ish -> 400
GET http://localhost:8080/api/v1/tasks/not-a-uuid
Accept: application/json

And here is another request, for a different kind of error, where the validator does not even get a chance to run because the type cannot be converted:

### page not a number -> 400 (type conversion error)
GET http://localhost:8080/api/v1/tasks?page=abc
Accept: application/json

This is the case where “format error” and “constraint violated” are different, but both scenarios are blocked at the boundary and do not let junk into the service.

7. Common mistakes when validating parameters

Mistake #1: thinking that @Valid on @RequestBody automatically covers @RequestParam and @PathVariable.
Validation of an object argument and validation of simple parameters are not the same mechanism. If page, size, sort, and taskId are part of the contract, the constraints need to be written directly on those parameters, not left to hope that one @Valid somewhere in the controller will “light everything up at once.”

Mistake #2: defaultValue does not satisfy the constraints and the endpoint contradicts itself.
If you set @RequestParam(defaultValue = "-1") @Min(0) int page, then when the client omits page you are creating invalid input yourself. It looks like “the server has broken default logic.” Always check that defaults fall within the allowed range.

Mistake #3: accepting page/size as String and doing manual parsing in the controller.
This quickly turns the controller into a “club of NumberFormatException enthusiasts.” Spring already knows how to convert types, and it does so earlier. When you leave it as String, you give up that mechanism and complicate the code without benefit.

Mistake #4: putting an overly heavy regular expression on sort and then failing to maintain it.
@Pattern is good as a whitelist and as a way to fix the format. But if the regular expression turns into 200 characters with no way to understand what is happening, the contract becomes unreadable. It is better to keep the format simple and explicitly limited than to try to cover “all possible sorting options in the world” with one regex blow.

Mistake #5: validate taskId “approximately,” and then inside the service still call UUID.fromString(taskId) without protection.
If you chose the strategy “taskId is a string,” then treat it as a string that has already passed validation. If, inside the service, you actually want a UUID, it is better to convert it in one place (and stick to one approach), otherwise you can end up in a situation where validation let the value through, but parsing still failed later.

1
Task
Spring REST & MVC, level 16, lesson 2
Locked
Catalog parameters directly in the controller signature
Catalog parameters directly in the controller signature
1
Task
Spring REST & MVC, level 16, lesson 2
Locked
UUID-like parcel identifier
UUID-like parcel identifier
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION