Validation and Exception Handling with Spring

Every time I start implementing a new REST API using Spring, I find it difficult to decide how to validate requests and handle business exceptions. Unlike other common API problems, Spring and its community do not seem to agree on the best practices for solving these problems, and it is hard to find helpful articles on the subject.

In this article, I summarize my experience and give some advice on interface validation.

Architecture and terminology

I create my own applications that provide Web-API, following the pattern  of onion architecture  ( Onion Architecture ) . This article is not about the Onion architecture, but I would like to mention some of its key points that are important in understanding my thoughts:

  • REST controllers  and any web components and configurations are part of the external  "infrastructure" layer  .

  • The middle  "service" layer  contains services that integrate business functions and solve common problems such as security or transactions.

  • The inner "domain" layer   contains business logic without any infrastructure related tasks such as database access, web endpoints, and so on.

A sketch of the onion architecture layers and the placement of typical Spring classes.
Spring.

, .   REST  :

  •    ยซยป.

  • - - .

  • ,    ,     ( ).

  •    , , , .

  • , .

  •   -.  .

  • , , .

Validation at the request, service level, and domain level.
, .

, :

  • .  ,   API .  , Jackson,  ,  @NotNull.     .

  • , .  .

  • , . .

   , . Spring Boot Jackson . ,      BGG:

@GetMapping("/newest")
Flux<ThreadsPerBoardGame> getThreads(@RequestParam String user, @RequestParam(defaultValue = "PT1H") Duration since) {
    return threadService.findNewestThreads(user, since);
}

          :

curl -i localhost:8080/threads/newest
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 189

{"timestamp":"2020-04-15T03:40:00.460+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Required String parameter 'user' is not present","requestId":"98427b15-7"}

curl -i "localhost:8080/threads/newest?user=chrigu&since=a"
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 156

{"timestamp":"2020-04-15T03:40:06.952+0000","path":"/threads/newest","status":400,"error":"Bad Request","message":"Type mismatch.","requestId":"7600c788-8"}

Spring Boot    .  ,

server:
  error:
    include-stacktrace: never

 application.yml .    BasicErrorController   Web MVC  DefaultErrorWebExceptionHandler  WebFlux, ErrorAttributes.

  @RequestParam  .   @ModelAttribute , @RequestBody  ,

@GetMapping("/newest/obj")
Flux<ThreadsPerBoardGame> getThreads(@Valid ThreadRequest params) {
    return threadService.findNewestThreads(params.user, params.since);
}

static class ThreadRequest {
    @NotNull
    private final String user;
    @NotNull
    private final Duration since;

    public ThreadRequest(String user, Duration since) {
        this.user = user;
        this.since = since == null ? Duration.ofHours(1) : since;
    }
}

@RequestParam ,       ,     bean-,  @NotNull Java / Kotlin.  bean-,  @Valid.

bean- ,  BindException  WebExchangeBindException .  BindingResult, .  ,

curl "localhost:8080/java/threads/newest/obj" -i
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 1138

{"timestamp":"2020-04-17T13:52:39.500+0000","path":"/java/threads/newest/obj","status":400,"error":"Bad Request","message":"Validation failed for argument at index 0 in method: reactor.core.publisher.Flux<ch.chrigu.bgg.service.ThreadsPerBoardGame> ch.chrigu.bgg.infrastructure.web.JavaThreadController.getThreads(ch.chrigu.bgg.infrastructure.web.JavaThreadController$ThreadRequest), with 1 error(s): [Field error in object 'threadRequest' on field 'user': rejected value [null]; codes [NotNull.threadRequest.user,NotNull.user,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [threadRequest.user,user]; arguments []; default message [user]]; default message [darf nicht null sein]] ","requestId":"c87c7cbb-17","errors":[{"codes":["NotNull.threadRequest.user","NotNull.user","NotNull.java.lang.String","NotNull"],"arguments":[{"codes":["threadRequest.user","user"],"arguments":null,"defaultMessage":"user","code":"user"}],"defaultMessage":"darf nicht null sein","objectName":"threadRequest","field":"user","rejectedValue":null,"bindingFailure":false,"code":"NotNull"}]}

, , API.  Spring Boot:

curl "localhost:8080/java/threads/newest/obj?user=chrigu&since=a" -i
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Content-Length: 513

{"timestamp":"2020-04-17T13:56:42.922+0000","path":"/java/threads/newest/obj","status":500,"error":"Internal Server Error","message":"Failed to convert value of type 'java.lang.String' to required type 'java.time.Duration'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.Duration] for value 'a'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [a]","requestId":"4c0dc6bd-21"}

, , since.  , MVC .  .  , bean- ErrorAttributes ,    .  status.

DefaultErrorAttributes,   @ResponseStatus, ResponseStatusException .  .  , , , , .  - @ExceptionHandler . , , . , , (rethrow):

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TypeMismatchException::class)
    fun handleTypeMismatchException(e: TypeMismatchException): HttpStatus {
        throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid value '${e.value}'", e)
    }

    @ExceptionHandler(WebExchangeBindException::class)
    fun handleWebExchangeBindException(e: WebExchangeBindException): HttpStatus {
        throw object : WebExchangeBindException(e.methodParameter!!, e.bindingResult) {
            override val message = "${fieldError?.field} has invalid value '${fieldError?.rejectedValue}'"
        }
    }
}

Spring Boot , , , Spring.  , , , :

  •  try/catch (MVC)  onErrorResume() (Webflux).  , , , , .

  •   @ExceptionHandler .  @ExceptionHandler (Throwable.class) .

  •    , @ResponseStatus ResponseStatusException, .

Spring Boot , .  , , .

, .  , ,   , , Java Kotlin,    , ,  .   .




All Articles