概要
SpringBootでJSONを扱うRestController開発をすることを前提に、JSONのRequestRequestパラメータで、java.util.Date型に直接バインドする(Date型のパラメータを受け取る)実装について色々思うところがあって記事にしました。
構成
とりあえず、Todoを取り扱う(CRUDする)RestControllerのサンプルという前提で、以下の構成のプロジェクトとします。
※ServiceとかRepositoryとかありますが、Date型パラメータを受け取る部分(Controller部分)に直接関係しないので割愛します。
.
├── Application.java
├── controller
│ └── TodoController.java
├── handler
│ └── RestControllerExceptionHandler.java
└── vo
├── ErrorResponse.java
└── TodoParam.java
ソースの中身
TodoController
CRUD色々と処理はありますが、新規でTodoを登録するPOST処理部分だけ抜粋しております。
@Validatedによって、JSONパラメータの入力チェックを有効化しています。
package example.restcontroller_sample.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import example.restcontroller_sample.entity.Todo;
import example.restcontroller_sample.service.TodoService;
import example.restcontroller_sample.vo.TodoParam;
@RestController
public class TodoController {
@Autowired
private TodoService service;
@RequestMapping(path = "/todo", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE)
public Todo postTodo(@RequestBody @Validated TodoParam param) {
return service.postTodo(param);
}
................
}
TodoParam
タスクIDやタスク名、優先度など余計な属性をそれっぽく定義していますが、ここの記事で重要なのはtimelimit
の項目です。
java.util.Date型のtimelimitに対して、JSONパラメータで渡ってくる文字列の日付データを型変換して自動セットするようにしています。
package example.restcontroller_sample.vo;
import java.util.Date;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.ToString;
@Data
@ToString
public class TodoParam {
private String taskId;
@NotNull(message = "taskNameが指定されていません。")
private String taskName;
@NotEmpty(message = "statusが指定されていません。")
@Pattern(regexp = "^[0,1,2]{1}$", message = "statusは'0'または'1'または'2'を指定して下さい。")
private String status;
@NotEmpty(message = "priorityが指定されていません。")
@Pattern(regexp = "^[0-3]{1}$", message = "priorityは'0'〜'3'を指定して下さい。")
private String priority;
@JsonFormat(pattern="yyyy-MM-dd")
private Date timelimit;
}
RestControllerExceptionHandler
@Validatedによって自動的にバインドされた各パラメータの値に対して個別に入力チェック処理を行い、エラーが発生した場合などにhandleMethodArgumentNotValidException
メソッドでそのエラーをハンドルし、適切にエラー結果を返却しています。
package example.restcontroller_sample.handler;
import java.util.stream.Collectors;
import javax.persistence.EntityNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import example.restcontroller_sample.vo.ErrorResponse;
@RestControllerAdvice
public class RestControllerExceptionHandler {
/** ログ */
private Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* RestControllerで実行されたValidationの結果、エラーとなった状態をハンドルし、<br>
* http_status=400でエラーの原因メッセージと一緒に返却する。
* @param e
* @return {@link ErrorResponse}
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
var response = new ErrorResponse(910, e.getBindingResult().getAllErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.joining(",")));
logger.error(response.toString());
return response;
}
}
JSONパラメータを色々いじって動作確認
正しいフォーマットで日付を指定したとき
今回は、timelimitで受け取れる日付フォーマットがyyyy-MM-dd
としているため、JSONで指定する文字列もそのフォーマットに合わせて定義。
想定通り、 文字列型であるJSONの"2022-07-03"
が、java.util.Date型に型変換され、TODOの登録処理が完了しました。
{
"taskName" : "タスク①",
"status": 0,
"priority": 0,
"timelimit": "2022-07-03"
}
{
"created": "2021-06-07 20:42:48",
"updated": "2021-06-07 20:42:48",
"taskId": 2,
"taskName": "タスク①",
"status": 0,
"priority": "LOW",
"timelimit": "2022-07-03 00:00:00"
}
正しくないフォーマットで日付を指定したとき
文字列の日付から、java.util.Dateに変換するために、そのフォーマットを規定しているわけですが、そのルール通りの日付文字列がパラメータとして届かないこともあると思います。
その場合、どんな挙動となるでしょうか?
{
"taskName" : "タスク①",
"status": 0,
"priority": 0,
"timelimit": "2022/07/03"
}
見た感じは不正ではない2022/07/03
という文字列が、正しく変換できずにエラーが発生し、自分自身がコーディングしたわけではないエラーの情報がJSONとして返却されてしまいます。
{
"timestamp": "2021-06-07 20:41:49",
"status": 400,
"error": "Bad Request",
"path": "/todoapi/todo"
}
ちなみに、@Validatedで必須チェックなどをしているタスク名などの入力エラーがあった場合は、以下のようなResponseが想定通りに返却されます。
これは、RestControllerExceptionHandler
クラスで、Validation処理におけるエラー時に発生するMethodArgumentNotValidException
をハンドルすることで、予期したエラーとして、そのエラー情報を整形して返却できている状態です。
{
"errorCode": 910,
"message": "taskNameが指定されていません。"
}
必須項目であるタスク名を指定せずに不正な日付を指定した場合は?
@NotNullアノテーションによる必須チェックが定義されているtaskNameをRequestパラメータとして指定せず、
さらに不正な日付を指定したRequestを発生させた場合はどんな挙動になるか?
{
"status": 0,
"priority": 0,
"timelimit": "2022/07/03"
}
Validation処理よりも、java.util.Dateへの変換処理でエラーが発生する方が早い?ようで、エラーがResponseされてしまいます。
{
"timestamp": "2021-06-07 20:51:31",
"status": 400,
"error": "Bad Request",
"path": "/todoapi/todo"
}
ちなみに、発生しているエラーの情報は以下のようなエラーです。
[0;39m [2m:[0;39m org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.util.Date` from String "2022/07/03": expected format "yyyy-MM-dd"; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.util.Date` from String "2022/07/03": expected format "yyyy-MM-dd"
at [Source: (PushbackInputStream); line: 4, column: 16] (through reference chain: example.restcontroller_sample.vo.TodoParam["timelimit"])
timelimit以外の項目で複数のバリデーションエラーを発生させた場合
Date型であるtimelimitは正常な値を指定し、taskName/status/priorityに対して不正な値を指定した場合の挙動を確認します。
{
"status": 999,
"priority": 999,
"timelimit": "2022-07-03"
}
このように、複数項目に対するバリデーションの処理が全て実行され、何がいけないのかまとめて確認できます。
(もちろん、何がいけないのか、そのエラーメッセージが複数あった場合に、まとめて返却できるようにハンドラの処理を記述しているからなんですが。)
{
"errorCode": 910,
"message": "taskNameが指定されていません。,statusは'0'または'1'または'2'を指定して下さい。,priorityは'0'〜'3'を指定して下さい。"
}
Date型パラメータを受け取ることに対して何が言いたいのかというと
長文の記事になってしまい、なかなか言いたいことに辿り着いていない感じですみません。
何が言いたいかというと、
- java.util.Date型のパラメータを受け取ると、パラメータに対するバリデーションがまとめてできない。(ように感じる)
- そもそもDate型のパラメータへの型変換でエラーが出ているだけなので、その値に対するバリデーションができていない。
- Date型に変換できて初めて他の項目のバリデーションが動くし、Date型に対するバリデーション(例えば未来日付であることなど)が動くようになる
などのことが気になってしまい、あんまりDate型でパラメータを受け取るメリットが無いように感じました。
このRestControllerに対するパラメータは、どんなケースであっても不正な値は届かない。と言い切れるシステム要件がある場合には、型変換の手間を省くことができるのでいいと思いますが、RestAPIでパラメータの不正が無い前提のシステムってあるのでしょうか??
結論
自分なりの結論として、RestControllerで受け取るパラメータについては、Validationをしっかりやる前提で、全てのパラメータをStringで受け取り、チェック処理を完了したあとで、適切なデータ型へキャスとして後続のビジネスロジックで利用するのがいいのかなと思いました。
素人くさい意見なんでしょうか。。。この辺、みなさんどんな風に実装しているのか気になります。