kagamihogeの日記

kagamihogeの日記です。

WebFluxでBindingResultをWebExchangeBindExceptionに書き換え

WebFluxではWebMVCのようにメソッド引数でBindingResultでvalidation結果を取得できないので、その書き換え方について。

WebMVCではcontrollerでvalidation結果を取得するにはメソッドの引数にBindingResultを追加する。例えば以下のようなコードになる。以下では、もしvalidationエラーが発生したら、一旦BindExceptionに変換してcontroller-adviceでエラーハンドリングを行う、というのを想定している。これを書き換えていく。

import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SpringMvcConrtoller {

  @PostMapping("/item1")
  public Item item(@Validated Item item, BindingResult result) throws BindException {
    if (result.hasErrors()) {
      throw new BindException(result);
    }
    return new Item();
  }
}

ソースコード

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.0'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

書き換え例

引数にBindingResultがあると実行時エラーになるので削除する。また、引数オブジェクトをMonoとかでラップし、そのMonoを使用して処理を記述する。以下はmapで単に空オブジェクトを返すだけだが、こういう感じにロジックを記述する。もしvalidationが失敗するとWebExchangeBindExceptionになるのでこれをonErrorMapなどエラーハンドラで処理する。ここでは最初のSpringMVCの例に合わせて単にBindExceptionに詰め替えしている。*1

import org.springframework.validation.BindException;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.support.WebExchangeBindException;
import reactor.core.publisher.Mono;

@RestController
public class WebFluxController {
  @PostMapping("item001")
  public Mono<Item> item(@Validated Mono<Item> request) {
    return request
        .map(item -> new Item())
        .onErrorMap(WebExchangeBindException.class, e -> {
          return new BindException(e.getBindingResult());
        });
  }
}

エラーハンドリングはAbstractErrorWebExceptionHandlerの継承クラスを用意し、例外ごとの処理を記述する。こういうのはJDK 21のswitchの使用例になるだろうか。

import org.springframework.boot.autoconfigure.web.WebProperties.Resources;
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindException;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;

@Component
public class CustomErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {

  public CustomErrorWebExceptionHandler(ErrorAttributes errorAttributes,
      ApplicationContext applicationContext,
      ServerCodecConfigurer serverCodecConfigurer) {
    super(errorAttributes, new Resources(), applicationContext);
    super.setMessageWriters(serverCodecConfigurer.getWriters());
    super.setMessageReaders(serverCodecConfigurer.getReaders());
  }

  @Override
  protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
    return RouterFunctions.route(RequestPredicates.all(), r -> {
      ErrorAttributeOptions eao = ErrorAttributeOptions.defaults();

      Throwable error = errorAttributes.getError(r);
      return switch (error) {
        case BindException e -> ServerResponse.status(400).bodyValue("validation error");
        default -> ServerResponse.status(500).bodyValue("default");
      }
    });
  }
}

試行錯誤

以下はあーだこーだと調べたり試したりした際の作業メモ。

まずは単に戻り値型をMonoに変えるだけで実行してみる。

import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@RestController
public class WebFluxController {

  @PostMapping("item001")
  public Mono<Item> item(@RequestBody @Validated Item item, BindingResult result) throws BindException {
    if (result.hasErrors()) {
      throw new BindException(result);
    }
    return Mono.just(new Item());
  }

以下のエラーになる。

java.lang.IllegalStateException: An Errors/BindingResult argument is expected immediately after the @ModelAttribute argument to which it applies. For @RequestBody and @RequestPart arguments, please declare them with a reactive type wrapper and use its onError operators to handle WebExchangeBindException: public reactor.core.publisher.Mono org.example.app.WebFluxController.item(org.example.app.Item,org.springframework.validation.BindingResult) throws org.springframework.validation.BindException
    at org.springframework.util.Assert.state(Assert.java:101) ~[spring-core-6.2.0.jar:6.2.0]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ? HTTP POST "/item001" [ExceptionHandlingWebHandler]
Original Stack Trace:

エラーメッセージに従い、引数オブジェクトをMonoでラップする。

  @PostMapping("item001")
  public Mono<Item> item(@RequestBody @Validated Mono<Item> item, BindingResult result) throws BindException {
   ...

以下のエラーになる。

java.lang.IllegalStateException: An @ModelAttribute and an Errors/BindingResult argument cannot both be declared with an async type wrapper. Either declare the @ModelAttribute without an async wrapper type or handle a WebExchangeBindException error signal through the async type.
    at org.springframework.util.Assert.state(Assert.java:79) ~[spring-core-6.2.0.jar:6.2.0]
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    *__checkpoint ? HTTP POST "/item001" [ExceptionHandlingWebHandler]
Original Stack Trace:

エラーメッセージに従い、引数からBindingResultを削除してWebExchangeBindExceptionのエラーハンドリングに切り替える。これは上述の通り。

もし以下のようにWebExchangeBindExceptionをそのまま返すだけにしたらどうなるか? AbstractErrorWebExceptionHandlerに行かない。

  @PostMapping("item001")
  public Mono<Item> item(@Validated Mono<Item> request) {
    return request
        .map(item -> new Item())
        .onErrorMap(WebExchangeBindException.class, e -> {
          return e;
        });
  }

参考文献

*1:WebFluxほぼ経験ゼロなのでreactive由来の用語の使い方が変だと思うがスルーして欲しい