Spring WebFluxで自作WebFilterなどでServerHttpRequestからJSONボディを読み込むショートハンド

Spring Boot 2.3.4.RELEASE時点の環境の話です。

---

Spring WebFluxではHTTPリクエストを処理するための方法を幾つか提供してくれている。

この時にHTTPレスポンスデータを生成するために、
多くの場合は`RouterFunction`や`@Controller`を使用することが多いと思う。

`RouterFunction`には`ServerRequest`が渡されるので、`ServerRequest#bodyToMono(Class<? extends T>)`を呼び出しすれば
リクエストボディのJSONデータをパース出来る。

Spring Bootでは`@Controller`の場合は`@RequestBody`アノテーションが付与されていれば
フレームワーク側でリクエストボディをパースしてくれる。

ほとんどの場合はこの方法でリクエストボディのJSONをアプリケーション内で利用するオブジェクトに変換できる。

しかし、Spring Securityのカスタム認証はWebFilterの段階で処理を行うため、
`RouterFunction`などが呼び出される。

このとき`WebFilter`には`ServerWebExchange`が渡されるが、`ServerWebExchange#getRequest()`メソッドは
`ServerHttpRequest`を返す。

`ServerHttpRequest`は`ServerRequest`との間に継承関係なども持たないため、
`ServerRequest#bodyToMono(Class<? extends T>)`のような便利な関数を持たない。

`ServerWebExchange#getFormData()`でフォームデータの取得などはできるが、リクエストボディのJSONのパースなどは自分で行わなければいけない。

このパース処理をSpring Bootと同じようにするためには、`ServerCodecConfigurer`を`Autowired`で取得し、
`ServerCodecConfigurer#getReaders()`で`ListSE<HttpMessageReader<?>>`を取得、
HTTPリクエストボディをパースするための`HttpMessageReader`は`Stream#filter()`で取得して...といった面倒な作業が必要になる。

これをSpring Securityの`org.springframework.security.web.server.authentication.ServerAuthenticationConverter`で書くと大体以下のようになる。

```java
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import javax.naming.AuthenticationException;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.core.ResolvableType;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

public class JsonLoginServerAuthenticationConverter implements ServerAuthenticationConverter {

    private final List<HttpMessageReader<?>> httpMessageReaders;

    public JsonLoginServerAuthenticationConverter(ServerCodecConfigurer serverCodecConfigurer) {
        this(serverCodecConfigurer.getReaders());
    }

    public JsonLoginServerAuthenticationConverter(List<HttpMessageReader<?>> httpMessageReaders) {
        this.httpMessageReaders = Objects.requireNonNull(httpMessageReaders);
    }

    @Override
    public Mono<Authentication> convert(ServerWebExchange exchange) {

        ServerHttpRequest request = exchange.getRequest();
        MediaType contentType = request.getHeaders().getContentType();
        MediaType acceptType = MediaType.APPLICATION_JSON;

        // Content-Type のチェック。
        if (contentType == null || acceptType.isCompatibleWith(contentType)) {
            return Mono.error(new AuthenticationException("Invalid Content-Type"));
        }

        ResolvableType resolvableType = ResolvableType.forClass(JsonParameter.class);

        // JsonParameter は username, password をフィールドに格納するだけのクラス。
        // JsonAuthentication は JsonParameter を保存するだけの Authentication のサブクラス。
        // どちらも定義は省略。
        return this.httpMessageReaders.stream()
            // Content-Type: application/json のリクエストデータを JsonParameter.class に変換可能な
            // HttpMessageReader 実装を探す。
            .filter(httpMessageReader ->
                httpMessageReader.canRead(resolvableType, acceptType))
            .findFirst()
            .orElseThrow(() -> new IllegalStateException("Could not read JSON data."))
            .readMono(resolvableType, request, Collections.emptyMap())
            .cast(JsonParameter.class)
            .map(JsonAuthentication::new);
    }

    @JsonIgnoreProperties(ignoreUnknown = true)
    static class JsonParameter {
        private final String username;

        private final String password;

        @JsonCreator
        public JsonParameter(
            @JsonProperty("username") String username,
            @JsonProperty("password") String password) {
            this.username = username;
            this.password = password;
        }

        public String getUsername() {
            return this.username;
        }

        public String getPassword() {
            return this.password;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            JsonParameter that = (JsonParameter) o;
            return Objects.equals(username, that.username) &&
                Objects.equals(password, that.password);
        }

        @Override
        public int hashCode() {
            return Objects.hash(username, password);
        }

        @Override
        public String toString() {
            return "JsonParameter(" +
                "username=" + this.username +
                ", password=" + this.password +
                ")";
        }

    }
}
```

正直、この実装はかなり面倒だと思ってしまう。

非同期I/Oで何時送られれ来るか分からないリクエストボディを処理する事と、
Spring Bootでメッセージボディを処理するオブジェクトに何が使用されるか実行時まで確定しないという都合から
こうなるのは理解できる。

しかし、Servert APIとは異なる独自のHTTPメッセージ処理であることを差し引いても
この手順を毎回書くのは非常に厄介に思えた。

`Mono`が使用されている都合上、便利なユーティリティー関数を用意するのも少し面倒になる。

などと思っていたが、ある日`ServerRequest#create(ServerWebExchange, List<HttpMessageReader<?>>)`関数が存在することに気が付いた。
この関数で生成された`ServerRequest`はメッセージボディの処理に与えられた`List<HttpMessageReader<?>>`の中から
適切な`HttpMessageReader`を取り出して使用してくれる。

メッセージボディを何に変換したいのかは`BodyExtractors`に用意されている関数から`BodyExtractor`を生成するだけでよい。

先ほどのコードでもコード量を大きく減らすことが出来る。
`Content-Type`のチェックは念のため残している。

```java
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Objects;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.web.reactive.function.BodyExtractors;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

public class JsonLoginServerAuthenticationConverter implements ServerAuthenticationConverter {

    private final List<HttpMessageReader<?>> httpMessageReaders;

    public JsonLoginServerAuthenticationConverter(ServerCodecConfigurer serverCodecConfigurer) {
        this(serverCodecConfigurer.getReaders());
    }

    public JsonLoginServerAuthenticationConverter(List<HttpMessageReader<?>> httpMessageReaders) {
        this.httpMessageReaders = Objects.requireNonNull(httpMessageReaders);
    }

    @Override
    public Mono<Authentication> convert(ServerWebExchange exchange) {
        ServerHttpRequest request = exchange.getRequest();
        MediaType contentType = request.getHeaders().getContentType();

        if (contentType == null || MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
            return Mono.error(new AuthenticationException("Invalid Content-Type"));
        }

        return ServerRequest.create(exchange, this.httpMessageReaders)
            .body(BodyExtractors.toMono(JsonParameter.class))
            .map(JsonAuthentication::new);
    }
}
```

`ServerRequest#create(ServerWebExchange, List<HttpMessageReader<?>>)`は`WebFilter`で有用な機能なのに、
あまり紹介されていない。