IK.AM

@making's tech note


Spring WebFlux.fnハンズオン - 3. YAVIによるValidationの実装


本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。

ハンズオンコンテンツ

  1. はじめに
  2. 簡易家計簿Moneygerプロジェクトの作成
  3. YAVIによるValidationの実装 👈
  4. R2DBCによるデータベースアクセス
  5. Web UIの追加
  6. 例外ハンドリングの改善
  7. 収入APIの実装
  8. Spring Bootアプリに変換
  9. GraalVMのSubstrateVMでNative Imageにコンパイル

YAVIによるValidationの実装

次はValidationを実装します。今回はBean Validationではなく、Spring WebFlux.fnによりFitした使い方ができるYAVIを使用します。

TODO部分を実装してください。動作を確認するためのテストコードは以下に続きます。TODOを実装する前にテストを実行してくだい。

package com.example.expenditure;

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.ConstraintViolations;
import am.ik.yavi.core.Validator;
import am.ik.yavi.fn.Either;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.time.LocalDate;

@JsonDeserialize(builder = ExpenditureBuilder.class)
public class Expenditure {

    private final Integer expenditureId;

    private final String expenditureName;

    private final int unitPrice;

    private final int quantity;

    private final LocalDate expenditureDate;

    // 追加
    private static Validator<Expenditure> validator = ValidatorBuilder.of(Expenditure.class)
        .constraint(Expenditure::getExpenditureId, "expenditureId", c -> c.isNull())
        // TODO
        // "expenditureName"は空ではなく、文字数は255以下
        // "unitPrice"は0より大きい
        // "quantity"は0より大きい
        // .constraint(...)
        .constraintOnObject(Expenditure::getExpenditureDate, "expenditureDate", c -> c.notNull())
        .build();

    Expenditure(Integer expenditureId, String expenditureName, int unitPrice, int quantity, LocalDate expenditureDate) {
        this.expenditureId = expenditureId;
        this.expenditureName = expenditureName;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
        this.expenditureDate = expenditureDate;
    }

    public Integer getExpenditureId() {
        return expenditureId;
    }


    public String getExpenditureName() {
        return expenditureName;
    }


    public int getUnitPrice() {
        return unitPrice;
    }


    public int getQuantity() {
        return quantity;
    }


    public LocalDate getExpenditureDate() {
        return expenditureDate;
    }


    // 追加
    public Either<ConstraintViolations, Expenditure> validate() {
        return validator.validateToEither(this);
    }

    @Override
    public String toString() {
        return "Expenditure{" +
            "expenditureId=" + expenditureId +
            ", expenditureName='" + expenditureName + '\'' +
            ", unitPrice=" + unitPrice +
            ", quantity=" + quantity +
            ", expenditureDate=" + expenditureDate +
            '}';
    }
}

次にエラーレスポンス用のJavaクラスを作成します。 com.example.errorパッケージを作ってErrorResponse.javaを作成してください。

package com.example.error;

import com.fasterxml.jackson.annotation.JsonInclude;

import java.util.List;
import java.util.Map;

public class ErrorResponse {

    private final int status;

    private final String error;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final Map<String, List<String>> details;

    public ErrorResponse(int status, String error, String message, Map<String, List<String>> details) {
        this.status = status;
        this.error = error;
        this.message = message;
        this.details = details;
    }

    public int getStatus() {
        return status;
    }

    public String getError() {
        return error;
    }

    public String getMessage() {
        return message;
    }

    public Map<String, List<String>> getDetails() {
        return details;
    }
}

続いてErrorResponseBuilder.javaを作成してください。

package com.example.error;

import am.ik.yavi.core.ConstraintViolations;
import org.springframework.http.HttpStatus;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Collections;
import java.util.List;
import java.util.Map;

public class ErrorResponseBuilder {

    private Map<String, List<String>> details;

    private String error;

    private String message;

    private int status;

    public ErrorResponse build() {
        return new ErrorResponse(status, error, message, details);
    }

    public ErrorResponseBuilder withDetails(Map<String, List<String>> details) {
        this.details = details;
        return this;
    }

    public ErrorResponseBuilder withDetails(ConstraintViolations violations) {
        MultiValueMap<String, String> details = new LinkedMultiValueMap<>();
        violations.details().forEach(d -> details.add((String) d.getArgs()[0], d.getDefaultMessage()));
        this.details = Collections.unmodifiableMap(details);
        return this;
    }

    public ErrorResponseBuilder withMessage(String message) {
        this.message = message;
        return this;
    }

    public ErrorResponseBuilder withStatus(HttpStatus status) {
        this.status = status.value();
        this.error = status.getReasonPhrase();
        return this;
    }
}

ExpenditureHandlergetメソッドの次の部分(LinkedHashMapでエラーメッセージを作成している箇所)を、

    Mono<ServerResponse> get(ServerRequest req) {
        return this.expenditureRepository.findById(Integer.valueOf(req.pathVariable("expenditureId")))
            .flatMap(expenditure -> ServerResponse.ok().bodyValue(expenditure))
            .switchIfEmpty(Mono.defer(() -> ServerResponse.status(NOT_FOUND).bodyValue(new LinkedHashMap<String, Object>() {

                {
                    put("status", 404);
                    put("error", "Not Found");
                    put("message", "The given expenditure is not found.");
                }
            })));
    }

次のようにErrorResponseで置き換えてください。

    Mono<ServerResponse> get(ServerRequest req) {
        return this.expenditureRepository.findById(Integer.valueOf(req.pathVariable("expenditureId")))
            .flatMap(expenditure -> ServerResponse.ok().bodyValue(expenditure))
            .switchIfEmpty(Mono.defer(() -> ServerResponse.status(NOT_FOUND)
                .bodyValue(new ErrorResponseBuilder()
                    .withMessage("The given expenditure is not found.")
                    .withStatus(NOT_FOUND)
                    .build())));
    }

また次のpostメソッドにValidationを追加します。

    Mono<ServerResponse> post(ServerRequest req) {
        return req.bodyToMono(Expenditure.class)
            .flatMap(this.expenditureRepository::save)
            .flatMap(created -> ServerResponse
                .created(UriComponentsBuilder.fromUri(req.uri()).path("/{expenditureId}").build(created.getExpenditureId()))
                .bodyValue(created));
    }

postメソッドを次のように変更してください。

    Mono<ServerResponse> post(ServerRequest req) {
        return req.bodyToMono(Expenditure.class)
            .flatMap(expenditure -> expenditure.validate()
                .bimap(v -> new ErrorResponseBuilder().withStatus(BAD_REQUEST).withDetails(v).build(), this.expenditureRepository::save)
                .fold(error -> ServerResponse.badRequest().bodyValue(error),
                    result -> result.flatMap(created -> ServerResponse
                        .created(UriComponentsBuilder.fromUri(req.uri()).path("/{expenditureId}").build(created.getExpenditureId()))
                        .bodyValue(created))));
    }

ExpenditureHandlerTestpost_400メソッドに付いているコメントを、

    // TODO 後で実装します
    // @Test
    void post_400() {
      // ...
    }

次のように削除してください。

    @Test
    void post_400() {
      // ...
    }

TODOを実装しないでテストを実行すると次のようにpost_400のテストが失敗します。

image

java.lang.AssertionError: 
Expecting:
 <2>
to be equal to:
 <5>
but was not.

> POST /expenditures
> Content-Length: [95]
> Content-Type: [application/json]
> WebTestClient-Request-Id: [8]

{"expenditureId":1000,"expenditureName":"","unitPrice":-1,"quantity":-1,"expenditureDate":null}

< 400 BAD_REQUEST Bad Request
< Content-Type: [application/json]
< Content-Length: [158]

{"status":400,"error":"Bad Request","details":{"expenditureId":["\"expenditureId\" must be null"],"expenditureDate":["\"expenditureDate\" must not be null"]}}

ExpenditureクラスのTODOを実装して、テストが通ることを確認してください。

TODOの実装例は次の通りです。

Expenditureの正解例
package com.example.expenditure;

import am.ik.yavi.builder.ValidatorBuilder;
import am.ik.yavi.core.ConstraintViolations;
import am.ik.yavi.core.Validator;
import am.ik.yavi.fn.Either;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.time.LocalDate;

@JsonDeserialize(builder = ExpenditureBuilder.class)
public class Expenditure {

    private final Integer expenditureId;

    private final String expenditureName;

    private final int unitPrice;

    private final int quantity;

    private final LocalDate expenditureDate;

    private static Validator<Expenditure> validator = ValidatorBuilder.of(Expenditure.class)
        .constraint(Expenditure::getExpenditureId, "expenditureId", c -> c.isNull())
        .constraint(Expenditure::getExpenditureName, "expenditureName", c -> c.notEmpty().lessThanOrEqual(255))
        .constraint(Expenditure::getUnitPrice, "unitPrice", c -> c.greaterThan(0))
        .constraint(Expenditure::getQuantity, "quantity", c -> c.greaterThan(0))
        .constraintOnObject(Expenditure::getExpenditureDate, "expenditureDate", c -> c.notNull())
        .build();

    Expenditure(Integer expenditureId, String expenditureName, int unitPrice, int quantity, LocalDate expenditureDate) {
        this.expenditureId = expenditureId;
        this.expenditureName = expenditureName;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
        this.expenditureDate = expenditureDate;
    }

    public Integer getExpenditureId() {
        return expenditureId;
    }


    public String getExpenditureName() {
        return expenditureName;
    }


    public int getUnitPrice() {
        return unitPrice;
    }


    public int getQuantity() {
        return quantity;
    }


    public LocalDate getExpenditureDate() {
        return expenditureDate;
    }


    public Either<ConstraintViolations, Expenditure> validate() {
        return validator.validateToEither(this);
    }

    @Override
    public String toString() {
        return "Expenditure{" +
            "expenditureId=" + expenditureId +
            ", expenditureName='" + expenditureName + '\'' +
            ", unitPrice=" + unitPrice +
            ", quantity=" + quantity +
            ", expenditureDate=" + expenditureDate +
            '}';
    }
}

TODOを実装して、全てのテストが成功したら、Appクラスのmainメソッドを実行して、次のリクエストを送り、正しくレスポンスが返ることを確認してください。

$ curl localhost:8080/expenditures -d "{\"expenditureId\":1000,\"expenditureName\":\"\",\"unitPrice\":-1,\"quantity\":-1,\"expenditureDate\":null}" -H "Content-Type: application/json"
{"status":400,"error":"Bad Request","details":{"expenditureId":["\"expenditureId\" must be null"],"expenditureName":["\"expenditureName\" must not be empty"],"unitPrice":["\"unitPrice\" must be greater than 0"],"quantity":["\"quantity\" must be greater than 0"],"expenditureDate":["\"expenditureDate\" must not be null"]}}

✒️️ Edit  ⏰ History  🗑 Delete