3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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を返す。

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

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

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

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

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のチェックは念のため残している。

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で有用な機能なのに、
あまり紹介されていない。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?