validationをどこで実行するかでDomainへの変換ではmust,Controllerでは表層的なルールだけバリデーションする。また、Controllerで受け取った値は直接Domainへ変換せず一度DTOで受け取ってバリデーション、その後Domainへ変換するしたほうが良さそうと整理した。というわけで、実際のコードに起こしてみる。題材はそのままゆうパケットで、コードの全体はValidationとRailwayプログラミングを参照。

目次

  • API
    • 題材
    • PATH設計
  • 実装
    • Controller
    • Exception
  • 動作確認
    • リクエスト失敗
    • リクエスト成功

API

題材

Controllerがクライアントから情報を受け取る方法は多分大体2パターンありPOSTでJsonを投げ込んでもらうか、GETでURLパラメータとして渡してもらうかのどっちか。題材としてはゆうパケットではあるが、前回はPOSTパターンを書いたため今回はGETのパターンでコードに起こしてみたい。

GETなので参照系のAPIを題材に、、、ゆうパケット追跡番号を指定しgetで問い合わせれば「送り主、宛名、配送ステータス」を返すAPIを考えてみる。URLパラメータも使用したいので、パラメータに「送り主、宛名、配送ステータス」を指定されたら、そのその指定された値のみ返却するフィルタ機能も付けることにする イメージこんな感じ。

% curl -X GET "/yu-paket/ゆうパケット追跡番号"
{
  "sender": "山田 太郎",
  "recipient": "山田 花子",
  "deliveryStatus": "配送中"
}

PATH設計

ぱっと思いつくPATHは配送ステータスとかをパラメータとみなして/yu-paket/{trackingNumber}?filed=deliveryStatusかな。が、配送ステータスはリソースとみなしても良い気がしてきたので/yu-paket/{trackingNumber}/deliveryStatusでもいい気がしてきた。

配送ステータスをリソースとして見なす場合は、追跡番号指定で返ってくるゆうパケットとは独立したライフサイクルにあるはずなのでPOST /yu-paket/{trackingNumber}/deliveryStatusとかで登録するするしDELETE /yu-paket/{trackingNumber}/deliveryStatusで削除される運用になるはず。そんなに違和感無いか。

仮にこのシステムがイベント駆動アーキテクチャを採用しているなら、APIで状態を更新するのはおかしい気がしてくる。状態を変えるイベントを連携しろよって思う。そう考えると配送ステータスとかはパラメータで、/yu-paket/{trackingNumber}/あとに紐づけられるリソースではないと整理するのが適当か。。。?まー、APIのイメージこんな感じで。

フィルタ 無し

% curl -X GET "localhost:8080/yu-paket/{trackingNumber}"
{
  "sender": "山田 太郎",
  "recipient": "山田 花子",
  "deliveryStatus": "配送中"
}

フィルタ あり

% curl -X GET "localhost:8080/yu-paket/{trackingNumber}?filed=deliveryStatus"
{
  "deliveryStatus": "配送中"
}

実装

ディレクトリ構成

yu_packet配下にreferを切った。渡されるformは登録でも使い回せようにrefer,registrationと同じ階層に作成した。

src/main/java/com/example/demo/
├── DemoApplication.java
├── controller
│   └── yu_packet
│       ├── form
│       │   ├── FieldForm.java
│       │   └── YuPacketTrackingNumberForm.java
│       ├── refer
│       │   └── YuPaketReferController.java
│       └── registration
├── domain
├── exception
│   └── GlobalRestControllerExceptionHandler.java
└── service

controller

YuPaketReferController.java

URLパラメータやPATHをバリデーションする場合はControllerクラス自体に@Validatedアノテーションを付与し、さらにバリデーションしたい要素に@Validを付与する必要がある。

package com.example.demo.controller.yu_packet.refer;

import com.example.demo.controller.yu_packet.form.FieldForm;
import com.example.demo.controller.yu_packet.form.YuPacketTrackingNumberForm;
import io.vavr.control.Option;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Validated
public class YuPaketReferController {
  @RequestMapping(value = "/yu-paket/{trackingNumber}", method = RequestMethod.GET)
  public ResponseEntity<?> invoke(
      @Valid @NotNull @PathVariable("trackingNumber")
          YuPacketTrackingNumberForm yuPacketTrackingNumber,
      @Valid @RequestParam(value = "field", required = false) FieldForm field) {

    // 処理書くのは面倒なため、データは取得できた体で仮置きする
    Map<String, String> result = new HashMap<>();
    result.put("sender", "山田 太郎");
    result.put("recipient", "山田 花子");
    result.put("deliveryStatus", "配送中");

    // フィールドをフィルタ
    Map<String, String> FilteredData =
        Option.of(field).map(v -> filterFiled(result, v.getValue())).getOrElse(result);

    return ResponseEntity.ok(FilteredData);
  }

  // フィルタ
  private Map<String, String> filterFiled(Map<String, String> data, String FilterField) {
    return data.entrySet().stream()
        .filter(v -> v.getKey().equals(FilterField))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
  }
}

form

yu_packet関連のAPIで外部とやり取りされる値は使い回せる可能性が高いため、再利用を狙ってrefer,registrationと同じ階層にformディレクトリを作成している。ちゃんと整理できるならもう一回層上に上げても良いかもしれない。

また、バリデーション云々の前に値をクラスにマッピングして貰わないといけない。Springは先に引数無しでインスタンスを作成し後で値を設定していくため、formクラスには@NoArgsConstructor@Dataアノテーションを付与する必要がある。

YuPacketTrackingNumberForm.java

よくある文字数制限などはすでに提供されているもの(@Size等)を使用し、少し複雑なものは@AssertTrueアノテーションを使用してバリデーションをする。

@AssertTrueでのバリデーションはこのアノテーションを付与したメソッドが実行され、falseが返る場合は指定したmesage(“YuPacket Tracking Number must start with YPKET”)をexceptionにくっつけて渡してくれる。このメソッドを増やしていけばいくらでもバリデーションを追加することができる。注意点としてこの@AssertTrueアノテーションを付与するメソッド名はisから始まらなければならない(気づかなかった。。ムズすぎるだろ)。今回は適当にゆうパケット追跡番号のプレフィックスがYPKETから始まる必要があるとかにしてみた。

package com.example.demo.controller.yu_packet.form;

import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class YuPacketTrackingNumberForm {
  @Valid
  @Size(min = 10, max = 10, message = "YuPacket Tracking Number must be 10 characters")
  private String value;

  @AssertTrue(message = "YuPacket Tracking Number must start with YPKET")
  private boolean isPrefixYPKET() {
    return value.startsWith("YPKET");
  }
}
FieldForm.java

YuPacketTrackingNumberFormと同じ。

package com.example.demo.controller.yu_packet.form;

import jakarta.validation.constraints.AssertTrue;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class FieldForm {
  private String value;
  private static List<String> allowedValues = List.of("sender", "recipient", "deliveryStatus");

  @AssertTrue(message = "field must be sender, recipient, deliveryStatus")
  private boolean isType() {
    return allowedValues.contains(value);
  }
}

Exception

form側で投げられたexceptionは何もしなければSpringがデフォルトの処理に従って処理してしまうため、当然ながら500 Internal Server Eroerを返してしまう。exceptionだし。

そこで投げられたExceptionを共通で処理し、400 BadRequestを返すクラスを作成する。@ControllerAdviceを付ければコントローラーで投げられたExceptionを捕捉してくれるクラスを作れる。また、@ControllerAdvice(annotations = RestController.class)と書けば、更に絞って@RestControllerがついているクラスに絞ることもできる。 これでコントローラーのExceptionを捕捉するクラスができるため、あとはでSpring Bootでのエラーレスポンの制御にて実施したように捕捉したいExceptionごとに処理をするか書けばいい。

(たぶん)コントローラーで受け取った値をformにマッピングする際にSpringがバリデーションしてくれるため、form側で投げられるExcepitonはConstraintViolationException。これが投げられたら400 BadRequestを返すメソッドを書く。便利なことにConstraintViolationExceptionはバリデーションをパスしなかった際のエラーメッセージを全て持っているため、まとめて返すことができる。

package com.example.demo.exception;

import jakarta.validation.ConstraintViolationException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;

@ControllerAdvice(annotations = RestController.class)
public class GlobalRestControllerExceptionHandler {

  @ExceptionHandler(ConstraintViolationException.class)
  public ResponseEntity<Map> handleConstraintViolationException(ConstraintViolationException e) {
    // status
    HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

    // header
    MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
    headers.add("Content-Type", "application/json; charset=UTF-8");
    HttpHeaders httpHeaders = new HttpHeaders(headers);

    // body
    List<String> errors = e.getConstraintViolations().stream().map(v -> v.getMessage()).toList();
    Map<String, List<String>> body = new HashMap<>();
    body.put("validation errors", errors);

    return ResponseEntity.status(httpStatus).headers(httpHeaders).body(body);
  }
}

動作確認

起動

% ./gradlew clean bootRun

リクエスト失敗

ちゃんとPATHとURLパラメータのバリデーションエラー両方が返却されてる!

% curl -X GET "localhost:8080/yu-paket/aaa?field=deliveryStatuss" | jq .

{
  "validation errors": [
    "YuPacket Tracking Number must be 10 characters",
    "YuPacket Tracking Number must start with YPKET",
    "field must be sender, recipient, deliveryStatus"
  ]
}

リクエスト成功

あまり本題ではないけど、一応成功パターンも確認しておく。

フィルター 無し
% curl -X GET "localhost:8080/yu-paket/YPKET12345" | jq .

{
  "sender": "山田 太郎",
  "recipient": "山田 花子",
  "deliveryStatus": "配送中"
}
フィルター あり
% curl -X GET "localhost:8080/yu-paket/YPKET12345?field=deliveryStatus" | jq .

{
  "deliveryStatus": "配送中"
}