Open8

spring boot で request を validation したいときのあれこれ

RyonsyRyonsy

validation path param

path param の検証結果を載せるが query param に関しても同じ結果になる。
エラーが発生するリクエスト。
GET http://localhost:8080/api/users/4

validation ルールのアノテーションを使用

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @GetMapping(value = "/{id}")
  public ResponseEntity<UserResponse> findById(
      @PathVariable @Pattern(regexp = "^[123]") String id) {

    return ResponseEntity.ok(userService.findById(id));
  }
}
response
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST

valid annotation を引数に追加

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @GetMapping(value = "/{id}")
  public ResponseEntity<UserResponse> findById(
      @PathVariable @Valid @Pattern(regexp = "^[123]") String id) {

    return ResponseEntity.ok(userService.findById(id));
  }
}

レスポンスは変わらず。

response
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST

valid annotation をクラスに追加

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {

  private final UserService userService;

  @GetMapping(value = "/{id}")
  public ResponseEntity<UserResponse> findById(
      @PathVariable @Pattern(regexp = "^[123]") String id) {

    return ResponseEntity.ok(userService.findById(id));
  }
}

レスポンスは変わらず。

response
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST

validated annotation を引数に追加

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @GetMapping(value = "/{id}")
  public ResponseEntity<UserResponse> findById(
      @PathVariable @Validated @Pattern(regexp = "^[123]") String id) {

    return ResponseEntity.ok(userService.findById(id));
  }

  @GetMapping
  public ResponseEntity<UserResponse> searchByUserName(
      @RequestParam(name = "userName") @Pattern(regexp = "^(taro)") String userName) {

    return ResponseEntity.ok(userService.searchByUsername(userName));
  }
}

レスポンスは変わらず。

response
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST

validated annotation をクラスに追加

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserController {

  private final UserService userService;

  @GetMapping(value = "/{id}")
  public ResponseEntity<UserResponse> findById(
      @PathVariable @Pattern(regexp = "^[123]", message = "pattern が違う。") String id) {

    return ResponseEntity.ok(userService.findById(id));
  }
}

ConstraintViolationException がスローされメッセージが設定可能。設定してない場合はデフォルトのメッセージがでる。
これパターン以外でも message を追加したがレスポンスには出なかった。

response
"status": 500,
"error": "Internal Server Error",
"trace": "jakarta.validation.ConstraintViolationException: findById.id: pattern が違う
"message": "findById.id: pattern が違う。"

結果

クラスに validated アノテーションをつけると ConstraintViolationException がスローされメッセージが設定可能。ただ、ステータス500のため400にしたい場合はハンドリングが必要になる。
それ以外は HandlerMethodValidationException でステータス400になる。

RyonsyRyonsy

validation request body

request class

request body
public class UserRequest {
  @NotNull(message = "Invalid first name.")
  private String firstName;

  @NotNull private String lastName;

  @NotNull
  @Range(min = 0, max = 100)
  private int age;

  @NotNull
  @Pattern(regexp = "(men|female)")
  private String gender;
}

valid も validated もつけない

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {

  private final UserService userService;

  @PostMapping
  public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {

    return ResponseEntity.ok(userService.register(request));
  }
}

validate されず、リクエスト成功した。

valid annotation を引数につける

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {

  private final UserService userService;

  @PostMapping
  public ResponseEntity<UserResponse> registerUser(@RequestBody @Valid UserRequest request) {

    return ResponseEntity.ok(userService.register(request));
  }
}

validate され設定したメッセージも表示された。

response
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException
"defaultMessage": "Invalid first name.",

valid annotation をクラスにつける

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {

  private final UserService userService;

  @PostMapping
  public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {

    return ResponseEntity.ok(userService.register(request));
  }
}

validate されず、リクエスト成功した。

validated annotation を引数につける

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @PostMapping
  public ResponseEntity<UserResponse> registerUser(@RequestBody @Validated UserRequest request) {

    return ResponseEntity.ok(userService.register(request));
  }
}

validate され設定したメッセージも表示された。

response
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException
"defaultMessage": "Invalid first name."

validated annotation をクラスにつける

controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserControl {

  private final UserService userService;

  @PostMapping
  public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {

    return ResponseEntity.ok(userService.register(request));
  }
}

validate されず、リクエスト成功した。

結果

引数に valid もじくは validated アノテーションをつけると MethodArgumentNotValidException がスローされ、メッセージも表示される。

RyonsyRyonsy

validate 方法の結論

path param, query param の validate
⇒ クラスに validated アノテーションをつける。
body の validate
⇒ 引数に valid アノテーションをつける。valid でも validated でも同じ結果だったが、validated はグループを指定でき使用するvalidを任意に出来るみたいだが、それをさせたくないため。request クラスはリクエストごとにクラスを作るほうがいいと思う。

RyonsyRyonsy

error handling

エラーハンドリングは ResponseEntityExceptionHandler を継承してハンドリングしたい例外を宣言する。ここでは ConstraintViolationException をハンドリングしてステータス400にして返せた。
body をどうするかは検討する必要があるがやりたいことはできた。

error handling
@Slf4j
@RestControllerAdvice
public class MyExceptionHandler extends ResponseEntityExceptionHandler {

  private static final HttpHeaders HEADERS = new HttpHeaders();

  @ExceptionHandler(ConstraintViolationException.class)
  public ResponseEntity<Object> handleConstraintViolation(
      ConstraintViolationException ex, WebRequest request) {

    log.warn("validation error.", ex.toString());
    String message =
        ex.getConstraintViolations().stream()
            .map(ConstraintViolation::getMessage)
            .collect(Collectors.joining(System.lineSeparator()));

    return handleExceptionInternal(ex, message, HEADERS, HttpStatus.BAD_REQUEST, request);
  }
}
response
HTTP/1.1 400 
Content-Type: text/plain;charset=UTF-8
Content-Length: 48
Date: Wed, 23 Apr 2025 23:23:16 GMT
Connection: close

5 以下の値にしてください
Invalid id.
RyonsyRyonsy

custom annotation

自前のアノテーションを生成。

custom annotation
@Documented
@Constraint(validatedBy = GenderValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Gender {
  String message() default "Invalid gender.";

  Class<?>[] groups() default {};

  Class<? extends Payload>[] payload() default {};
}

バリデーション処理を実装。

validation
import com.example.my_spring_boot.infrastructure.validation.customAnnotation.Gender;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class GenderValidator implements ConstraintValidator<Gender, String> {

  @Override
  public boolean isValid(String gender, ConstraintValidatorContext context) {
    if (gender == null) return false;

    return gender.matches("(men|female)");
  }
}

request に付与。

request
@Data
public class UserRequest {
  @NotNull(message = "Invalid first name.")
  private String firstName;

  @NotNull private String lastName;

  @NotNull
  @Range(min = 0, max = 100, message = "Invalid age.")
  private int age;

  @Gender private String gender;
}

exception handler で MethodArgumentNotValidException をハンドリング。
ハンドリング内容は要検討だが、一旦メッセージをだすだけ。

exception handler
@RestControllerAdvice
public class MyExceptionHandler extends ResponseEntityExceptionHandler {

  @Override
  public ResponseEntity<Object> handleMethodArgumentNotValid(
      MethodArgumentNotValidException ex,
      HttpHeaders headers,
      HttpStatusCode status,
      WebRequest request) {

    String message =
        ex.getBindingResult().getAllErrors().stream()
            .map(error -> error.getDefaultMessage())
            .collect(Collectors.joining(System.lineSeparator()));

    return handleExceptionInternal(ex, message, headers, status, request);
  }
}

レスポンス

response
HTTP/1.1 400 
Content-Type: text/plain;charset=UTF-8
Content-Length: 15
Invalid gender.
RyonsyRyonsy

もう少しちゃんとエラーハンドリング

ErrorResponse
@Data
public class ErrorResponse {
  private final HttpStatus status;
  private final String message;
  private final List<Map<String, String>> errors;
}
error handling
@Override
  public ResponseEntity<Object> handleMethodArgumentNotValid(
      MethodArgumentNotValidException ex,
      HttpHeaders headers,
      HttpStatusCode status,
      WebRequest request) {

    var errors =
        ex.getFieldErrors().stream()
            .map(error -> Map.of("field", error.getField(), "message", error.getDefaultMessage()))
            .toList();
    ErrorResponse errorResponse =
        new ErrorResponse(HttpStatus.BAD_REQUEST, "Validation error.", errors);

    return handleExceptionInternal(ex, errorResponse, headers, status, request);
  }
response
{
  "status": "BAD_REQUEST",
  "message": "Validation error.",
  "errors": [
    {
      "field": "age",
      "message": "0 から 100 の間にしてください"
    },
    {
      "field": "gender",
      "message": "Invalid gender."
    },
    {
      "field": "firstName",
      "message": "null は許可されていません"
    }
  ]
}
RyonsyRyonsy

custom annotation の単体テスト

テストパターンをいくつか用意してそれぞれの結果と invalid だった時のメッセージのテストなどやればとりあえずはいいかな。
テスト用のDTOは record を用いると簡単でいいかも。

GenderTest
package com.example.my_spring_boot.ut.validation;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.arguments;

import com.example.my_spring_boot.infrastructure.validation.GenderValidator;
import com.example.my_spring_boot.infrastructure.validation.customAnnotation.Gender;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(classes = {ValidationAutoConfiguration.class})
public class GenderTest {

  @Autowired private Validator validator;

  public record GenderDto(@Gender String gender) {}

  @ParameterizedTest
  @MethodSource("genderProvider")
  void testGenderValidator(String gender, boolean expected) {

    GenderValidator validator = new GenderValidator();
    assertThat(validator.isValid(gender, null)).isEqualTo(expected);
  }

  static Stream<Arguments> genderProvider() {
    return Stream.of(
        arguments("men", true),
        arguments("female", true),
        arguments("dansei", false),
        arguments("josei", false));
  }

  @Test
  void whenInvalidGender_thenValidationFails() {

    GenderDto dto = new GenderDto("dansei");

    Set<ConstraintViolation<GenderDto>> violations = validator.validate(dto);

    assertThat(violations.size()).isEqualTo(1);
    assertThat(violations.iterator().next().getMessage()).isEqualTo("Should be men or female.");
  }
}