CLOVER🍀

That was when it all began.

Spring WebFluxのFunctional Endpointsを試す

Spring WebFluxを使ったプログラミングスタイルには、Spring MVCと同じAnnotated ControllersとFunctional Endpointsが
ありますが、Annotated Controllersの方しか試していなかったので、そろそろFunctional Endpointsも試してみようかと。

Spring WebFlux / Annotated Controllers

Functional Endpoints

Functional Endpoints?

Functional Endpointsの冒頭部分とOverviewを読むと、こういうことみたいです。

Functional Endpoints

Overview

  • 軽量なFuncationalプログラミングモデル
  • ルーティングとリクエストのハンドリングを行う、契約に基づいたイミュータブルなデザイン
  • Annotated Controllersと同じ基盤上で実行される
  • ServerRequestを受け取り、Monoを返すHandlerFunctionでHTTPリクエストを扱う
  • HTTPリクエストおよびレスポンスに対して、JDK 8フレンドリーでイミュータブルなアクセス方法を提供する
  • HandlerFunctionは、RouterFunctionによりマッピングされ、これは@RequestMappingアノテーションと同じ
  • RouterFunctions#routeは、多数のビルトインされたデフォルト実装を提供する

要するに、HTTPリクエスト、レスポンスを関数で扱うように実装できます、って話です。それがSpring WebFluxの
Reactiveな基盤(Reactor Netty)上で動きます、と。

Spring Boot側のサンプルコードは、こちら。
https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples/spring-boot-sample-webflux

まあ、あとはコードを書いていった方が早いと思いますので、さっさと移りましょう。

環境

実行環境は、こちら。

$ java -version
openjdk version "1.8.0_171"
OpenJDK Runtime Environment (build 1.8.0_171-8u171-b11-0ubuntu0.18.04.1-b11)
OpenJDK 64-Bit Server VM (build 25.171-b11, mixed mode)


$ mvn -version
Apache Maven 3.5.4 (1edded0938998edf8bf061f1ceb3cfdeccf443fe; 2018-06-18T03:33:14+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_171, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-30-generic", arch: "amd64", family: "unix"

準備

Mavenの定義は、こちら。

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <java.version>1.8</java.version>
        <spring-boot.version>2.0.4.RELEASE</spring-boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>junit</groupId>
                    <artifactId>junit</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.19.1</version>
                <dependencies>
                    <dependency>
                        <groupId>org.junit.platform</groupId>
                        <artifactId>junit-platform-surefire-provider</artifactId>
                        <version>1.1.1</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.jupiter</groupId>
                        <artifactId>junit-jupiter-engine</artifactId>
                        <version>5.1.1</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>

Spring WebFlux+Spring Test、テストコードはJUnit 5で動かします。

プログラムの作成

先に書いた通り、Functional Endpointsのスタイルでは、ルーティングの定義と、HTTPリクエスト/レスポンスを扱う
関数の定義が必要です。

ルーティングは、こんな感じで定義していきます。

    @Bean
    RouterFunction<ServerResponse> routes(BookHandler bookHandler) {
        return RouterFunctions
                .route(RequestPredicates.GET("/"),
                        request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")))
                .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register)
                .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete)
                .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find)
                .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll);
    }

RouterFunctions#routeを起点にして、RouterFunction#andRouteなど定義を追加しつつ、どのHTTPメソッドとパスに対して、
どういう関数を割り当てるかを書いていきます。

ここで割り当てる関数は、ServerRequestを受け取り、Monoを返すように作成すればOKです。小さい処理であれば、
以下のようにLambda式で書いてもOKです。

        return RouterFunctions
                .route(RequestPredicates.GET("/"),
                        request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")))

このサンプルは、このあとで全体のソースコードを書いていくので、そちらでまた…。

データとなるクラス

今回は、書籍データを受け取ったり返したりする、REST APIを書いていくことにします。

そのデータを表すクラスは、こちら。
src/main/java/org/littlewings/spring/functional/Book.java

package org.littlewings.spring.functional;

import java.util.Objects;

public class Book {
    String isbn;
    String title;
    int price;

    public static Book create(String isbn, String title, int price) {
        Book b = new Book();

        b.setIsbn(isbn);
        b.setTitle(title);
        b.setPrice(price);

        return b;
    }

    /** getter/setterは省略 **/

    /** equals/hashCodeも省略 **/
}
Repositoryを書く

このデータを扱う、Repositoryを用意します。Spring Data〜ななにかを使おうかとも思ったのですが、今回は簡単にMapで済ませることにしました。
src/main/java/org/littlewings/spring/functional/BookRepository.java

package org.littlewings.spring.functional;

import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.PostConstruct;

import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Repository
public class BookRepository {
    Map<String, Book> books = Collections.synchronizedMap(new LinkedHashMap<>());

    @PostConstruct
    void init() {
        List<Book> seedData = Arrays.asList(
                Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320),
                Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700)
        );

        seedData.forEach(b -> books.put(b.getIsbn(), b));
    }

    public Mono<Book> register(Book book) {
        books.put(book.getIsbn(), book);
        return Mono.just(book);
    }

    public Mono<Book> delete(String isbn) {
        return Mono.just(books.remove(isbn));
    }

    public Mono<Book> findByIsbn(String isbn) {
        return Mono.justOrEmpty(books.get(isbn));
    }

    public Flux<Book> findAll() {
        return Flux.fromIterable(books.values());
    }
}

データは、インスタンス構築時に持たせるスタイル。各メソッドの戻り値は、いずれもMono/Fluxとしてあります。

HandlerFunction

では、HandlerFunctionに相当するクラスを書いていきましょう。
※先にも書きましたが、簡単に済ませる場合はクラスを定義せずLambda式で済ませてもOKです

HandlerFunction

まず、雛形的には以下のように。
src/main/java/org/littlewings/spring/functional/BookHandler.java

package org.littlewings.spring.functional;

import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
public class BookHandler {
    BookRepository bookRepository;

    BookHandler(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    /** あとで **/
}

@Componentアノテーションを付与してクラスを宣言し、今回はDIの確認も兼ねたかったので先ほど作成したRepositoryをコンストラクタインジェクションするように
しています。

で、あとはCRUD的なメソッドを書いていきましょう。

登録、削除。

    public Mono<ServerResponse> register(ServerRequest request) {
        Mono<Book> book = request.bodyToMono(Book.class);

        return book
                .flatMap(bookRepository::register)
                .flatMap(b ->
                        ServerResponse
                                .created(request.uriBuilder().build())
                                .contentType(MediaType.APPLICATION_JSON)
                                .body(BodyInserters.fromObject(b)));
    }

    public Mono<ServerResponse> delete(ServerRequest request) {
        String isbn = request.pathVariable("isbn");

        return bookRepository
                .delete(isbn)
                .flatMap(b -> ServerResponse.noContent().build());
    }

いずれも、ServerRequestを引数に取り、Monoを戻り値とするメソッド定義になります。これに合わせて、ReactorのMonoやFluxを使った
プログラミングをしていくことになります。

リクエストの内容は、ServerRequestから取得します。POJOへの変換も、行えるようです。

        Mono<Book> book = request.bodyToMono(Book.class);

        String isbn = request.pathVariable("isbn");

レスポンスは、ServerResponseを使って組み立てます。HTTPボディの内容は、BodyInsertersを使って作ると良いみたいです。

        return book
                .flatMap(bookRepository::register)
                .flatMap(b ->
                        ServerResponse
                                .created(request.uriBuilder().build())
                                .contentType(MediaType.APPLICATION_JSON)
                                .body(BodyInserters.fromObject(b)));

1件取得や、全件取得の例は、こちら。

    public Mono<ServerResponse> find(ServerRequest request) {
        String isbn = request.pathVariable("isbn");

        return bookRepository
                .findByIsbn(isbn)
                .flatMap(b ->
                        ServerResponse
                                .ok()
                                .contentType(MediaType.APPLICATION_JSON)
                                .body(BodyInserters.fromObject(b)))
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> findAll(ServerRequest request) {
        Flux<Book> books = bookRepository.findAll();

        return ServerResponse
                .ok()
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromPublisher(books, Book.class));
    }
起動クラス(+RouterFunction)

では、最後に起動クラス。このクラスで、ルーティングの設定も行います。
src/main/java/org/littlewings/spring/functional/App.java

package org.littlewings.spring.functional;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.BodyInserters;
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;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }

    @Bean
    RouterFunction<ServerResponse> routes(BookHandler bookHandler) {
        return RouterFunctions
                .route(RequestPredicates.GET("/"),
                        request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")))
                .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register)
                .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete)
                .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find)
                .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll);
    }
}

ルーティングの定義は、RouterFunctionをBean定義することで行います。

RouterFunction

    @Bean
    RouterFunction<ServerResponse> routes(BookHandler bookHandler) {
        return RouterFunctions
                .route(RequestPredicates.GET("/"),
                        request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")))
                .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register)
                .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete)
                .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find)
                .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll);
    }

今回は、Hello World的なものを登録しつつ、HandlerFunctionで定義したメソッドをルーティングに紐付けるので作成したHandlerFunctionを
受け取りつつ、登録していきます。

RouterFunctionを複数Bean定義して、合成することもできそうな感じですね。

Running a server

こんな感じですね。

    @Bean
    RouterFunction<ServerResponse> helloRoutes() {
        return RouterFunctions
                .route(RequestPredicates.GET("/"),
                        request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")));
    }

    @Bean
    RouterFunction<ServerResponse> bookRoutes(BookHandler bookHandler) {
        return RouterFunctions
                .route(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register)
                .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete)
                .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find)
                .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll);
    }

HandlerFunctionの定義数が多い場合は、こういう感じで分割していくのでしょうかね。

設定

設定は、少しだけ。JSON出力時のインデントだけ、変えておきます。
src/main/resources/application.properties

spring.jackson.serialization.indent_output=true

確認

では、アプリケーションを起動して動作確認してみましょう。

$ mvn spring-boot:run

Nettyが起動します。

2018-08-11 23:19:05.949  INFO 18630 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port(s): 8080

起動したら、curlで確認していってみます。

Hello World

$ curl -i localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 27

Hello Functional Endpoint!!

全件取得。

$ curl -i localhost:8080/book
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json

[ {
  "isbn" : "978-4798142470",
  "title" : "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発",
  "price" : 4320
}, {
  "isbn" : "978-4777519699",
  "title" : "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発",
  "price" : 2700
} ]

1件取得。

$ curl -i localhost:8080/book/978-4798142470
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 143

{
  "isbn" : "978-4798142470",
  "title" : "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発",
  "price" : 4320
}

データ登録。

$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4774182179 -d '{"isbn": "978-4774182179", "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4104}'
HTTP/1.1 201 Created
Location: http://localhost:8080/book/978-4774182179
Content-Type: application/json
Content-Length: 172

{
  "isbn" : "978-4774182179",
  "title" : "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ",
  "price" : 4104
}

削除。

$ curl -i -XDELETE localhost:8080/book/978-4798142470
HTTP/1.1 204 No Content

確認。

$ curl -i localhost:8080/book/978-4798142470
HTTP/1.1 404 Not Found
content-length: 0

OKそうです。

テストを書く

それでは、最後にテストを書いてみましょう。

テストには、WebTestClientを使うことにします。

Testing / WebTestClient

まず最初に、MockServerを使うパターンで。

src/test/java/org/littlewings/spring/functional/MockTest.java
package org.littlewings.spring.functional;

import java.util.Arrays;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import reactor.core.publisher.Mono;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class MockTest {
    WebTestClient client;

    @Autowired
    RouterFunction<ServerResponse> routerFunction;

    @Autowired
    BookRepository bookRepository;

    @BeforeEach
    public void setUp() {
        client = WebTestClient.bindToRouterFunction(routerFunction).configureClient().build();
        bookRepository.books.clear();
        bookRepository.init();
    }

    @Test
    public void gettingStarted() {
        client
                .get()
                .uri("/")
                .exchange()
                .expectStatus()
                .isOk()
                .expectBody(String.class)
                .isEqualTo("Hello Functional Endpoint!!");
    }

    @Test
    public void findAll() {
        client
                .get()
                .uri("/book")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus()
                .isOk()
                .expectBodyList(Book.class)
                .isEqualTo(Arrays.asList(
                        Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320),
                        Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700)
                ));
    }

    @Test
    public void findOne() {
        client
                .get()
                .uri("/book/{isbn}", "978-4798142470")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus()
                .isOk()
                .expectBody(Book.class)
                .isEqualTo(Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320));
    }

    @Test
    public void register() {
        client
                .put()
                .uri("/book/{isbn}", "978-4774182179")
                .accept(MediaType.APPLICATION_JSON)
                .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)),
                        Book.class)
                .exchange()
                .expectStatus()
                .isCreated()
                .expectHeader()
                .valueEquals("Location", "/book/978-4774182179");

        client
                .get()
                .uri("/book/{isbn}", "978-4774182179")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus()
                .isOk()
                .expectBody(Book.class)
                .isEqualTo(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104));
    }

    @Test
    public void delete() {
        client
                .delete()
                .uri("/book/{isbn}", "978-4798142470")
                .exchange()
                .expectStatus()
                .isNoContent();

        client
                .get()
                .uri("/book/{isbn}", "978-4798142470")
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus()
                .isNotFound();
    }
}

コードは、JUnit 5を使っています。また、webEnvironmentはMOCKに設定。

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class MockTest {

RouterFunctionを@Autowiredして、WebTestClientにバインドします。

    WebTestClient client;

    @Autowired
    RouterFunction<ServerResponse> routerFunction;

    @Autowired
    BookRepository bookRepository;

    @BeforeEach
    public void setUp() {
        client = WebTestClient.bindToRouterFunction(routerFunction).configureClient().build();
        bookRepository.books.clear();
        bookRepository.init();
    }

あと、しょうもないことですが、テストデータを追加したり削除したりするので、今回はテストごとにRepositoryの持つデータを都度初期化…。

テスト自体は、見た目から類推は難しくないのでは、と。

ちなみに、RouterFunctionのBean定義を複数にした場合は、このままだとRouterFunctionが一意に定まらずにインジェクションが失敗するようになるので、
その場合はこんな感じにすればよい?

    @Autowired
    List<RouterFunction<ServerResponse>> routerFunctions;
    //RouterFunction<ServerResponse> routerFunction;

    @Autowired
    BookRepository bookRepository;

    @BeforeEach
    public void setUp() {
        client =
                WebTestClient
                        .bindToRouterFunction(routerFunctions.stream().reduce(RouterFunction::and).get())
                        .configureClient()
                        .build();

一応、これで動きましたが…。

続いて、組み込みのサーバーを使う場合。
src/test/java/org/littlewings/spring/functional/EmbeddedServerTest.java

package org.littlewings.spring.functional;

import java.util.Arrays;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class EmbeddedServerTest {
    @Autowired
    WebTestClient client;

    @LocalServerPort
    int localServerPort;

    @Autowired
    BookRepository bookRepository;

    @BeforeEach
    public void setUp() {
        bookRepository.books.clear();
        bookRepository.init();
    }

    /** テストコードは、Mockの時とほぼ同じ **/
}

Mockサーバーの時との差は、webEnvironmentをRANDOM_PORTにすることと、WebTestClientをDIしていること。

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class EmbeddedServerTest {
    @Autowired
    WebTestClient client;

    @LocalServerPort
    int localServerPort;

@LocalServerPortも使えるようになります。

あと、テストコードは1箇所だけ変わっていて、Locationヘッダを返しているところがMockだとこうなのですが

        client
                .put()
                .uri("/book/{isbn}", "978-4774182179")
                .accept(MediaType.APPLICATION_JSON)
                .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)),
                        Book.class)
                .exchange()
                .expectStatus()
                .isCreated()
                .expectHeader()
                .valueEquals("Location", "/book/978-4774182179");

組み込みサーバーだとこうなります。

        client
                .put()
                .uri("/book/{isbn}", "978-4774182179")
                .accept(MediaType.APPLICATION_JSON)
                .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)),
                        Book.class)
                .exchange()
                .expectStatus()
                .isCreated()
                .expectHeader()
                // ココだけ違う
                .valueEquals("Location", "http://localhost:" + localServerPort + "/book/978-4774182179");

URIにホスト名やポートが入ります、と。

まとめ

Spring WebFluxの、Functional Endpointsを試してみました。

慣れてないので、だいぶ手こずりました…。Annotated Controllersとどちらが良いか…ですが、せっかくなので、Functional Endpointsの方に慣れていこうかなぁと。

と、ここまで書いた後にセッションってどうなるか試してないなーとか、View使う場合はどうなるんだろうとかいうことに気づきましたが、まあいいや…。
※興味的に、Viewはやらないと思われる…