Kotlin × Spring Boot 3 で ConstraintViolationException のパラメータを変換する
概要
本記事は、筆者が Kotlin × Spring Boot 3 の組み合わせで、ConstraintViolationException からパラメータを取得するための実装を紹介する記事です。
ConstraintViolationException を用いてやりたかったことおよびできなかったことを説明し、最終的にどのような対処をしたのか解説します。
本記事で作成したサンプルコードは以下です。詳細な部分が気になれば活用してください。
本記事が扱う ConstraintViolationException の課題
ConstraintViolationException とはパスパラメータまたはクエリパラメータでバリデーションエラーが発生したときの例外です。
バリデーションエラーが発生した時に、どのパラメータからバリデーションエラーがどのように発生したかを取得したいです。
しかし、ConstraintViolationException からパラメータを取得ができません。
ConstraintViolationException から取得できるのは、メソッドのプロパティです。
たとえば、以下のような実装でバリデーションエラーが発生したときに、propertyPath が取得するのは、page-number
ではなく getHoge.pageNumber
になります。
fun getHoge(@RequestParam(value = "page-number", defaultValue = "0") @Min(0) Integer pageNumber): ResponseEntity<Any>
@ExceptionHandler(ConstraintViolationException::class)
fun constraintViolationExceptionExceptionHandler(
e: ConstraintViolationException
): ResponseEntity<Any> {
val messages = e.constraintViolations.map {
"${it.propertyPath}" // ここで取得できるのは、`page-number` ではなく `getHoge.pageNumber`
}
}
このままでは以下の 2 つの課題があります。
- 実装の詳細がレスポンスに含まれる
- API 仕様とバリデーションエラーの内容にズレが生じる(たとえば、openapi.yaml は
page-number
と書いているのに、レスポンスはpageNumber
と返す)
これらの課題を解決するために、ConstraintViolationException でフィールド名を変換した方法を記述します。
具体的に修正前だとどのような結果を説明した後に、修正後の動作確認します。
対処法 ConstraintViolation の Path クラスをパースする
修正前
修正前の実装
修正前の動作確認をしてみます。
たとえば、以下のようなサンプルコードがあるとします。
@PathVariable
、@RequestParam
でバリデーションエラーを設定しています。
slug、limit、offset、pageNumber のうち、pageNumber がキャメルケースで記述しています。
このままでは、それぞれ getArticle.slug や getArticle.pageNumber のように取得してしまいます。
@RestController
@Validated
class ArticleController {
@GetMapping("/articles/{slug}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun getArticle(
@PathVariable("slug") @Length(min = 1, max = 32) slug: String,
@RequestParam("limit", required = true) @NotNull @Max(20) limit: Int?,
@RequestParam("offset", required = true) @NotNull @Max(20) offset: Int?,
@RequestParam("page-number", required = true) @NotNull @Max(20) pageNumber: Int?,
): ResponseEntity<SingleArticleResponse> {
// 略
}
}
下記は、ConstraintViolationException をハンドリングするコントローラです。
propertyPath
に特に変更はありません。
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException::class)
fun constraintViolationExceptionExceptionHandler(
e: ConstraintViolationException
): ResponseEntity<GenericErrorModel> {
val messages = e.constraintViolations.map {
"${it.propertyPath}は${it.message}"
}
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = messages
),
),
HttpStatus.BAD_REQUEST
)
}
}
エラーメッセージは ValidationMessages.properties から取得します。
本記事の主目的ではないため、詳細な省略します。
エラーメッセージの設定。
jakarta.validation.constraints.NotBlank.message=必須です
jakarta.validation.constraints.NotNull.message=nullが許可されていません
jakarta.validation.constraints.Size.message={min}文字以上{max}文字以下にしてください
jakarta.validation.constraints.Max.message={value}以下にしてください
jakarta.validation.constraints.Min.message={value}以上にしてください
org.hibernate.validator.constraints.Length.message={min}文字以上{max}文字以下にしてください
修正前の動作確認
この状態でリクエストを送るとメソッド名からプロパティをそのまま取得しています。
具体的には、getArticle.slug や getArticle.pageNumber のようになっています。
$ curl --location 'http://localhost:8080/articles/012345678901234567890123456789013?limit=21&page-number=21' | jq .
{
"errors": {
"body": [
"getArticle.limitは20以下にしてください",
"getArticle.offsetはnullが許可されていません",
"getArticle.pageNumberは20以下にしてください",
"getArticle.slugは1文字以上32文字以下にしてください"
]
}
}
修正後
本記事の対処法では Path クラスが持っているプロパティをパースすることで、パラメータを取得しています。
リクエストパラメータで指定された内容を取得できたわけではないことに注意してください。
修正後の実装
修正後の実装は以下のようになります。
propertyPath が持つ変数は、メソッド名.プロパティ名
です。
そこで、.
で分割し、チェインケースに変更しています。
今回は例のため、ここでチェインケースを選んだ理由は得にありません。
API 設計の命名規則としてスネークケースやキャメルケースがあれば、合わせた実装にします。
そのため、API 側で統一された命名規則に従ってください。
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException::class)
fun constraintViolationExceptionExceptionHandler(
e: ConstraintViolationException
): ResponseEntity<GenericErrorModel> {
// ConstraintViolationException からレスポンスに利用するエラーメッセージを生成する
val messages = e.constraintViolations.map {
val chainCaseRequestParam = it.propertyPath.toString().split('.').last().split(Regex("(?=[A-Z])")).joinToString("-") { it.toLowerCase() }
"${chainCaseRequestParam}は${it.message}"
}
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = messages
),
),
HttpStatus.BAD_REQUEST
)
}
}
修正後の動作確認
curl リクエストで GET を送信してみます。
先ほどと異なり、メソッド名が表示されず、キャメルケース(pageNumber
)はスネークケース(page-number
)になりました。
そのため、API のリクエストとエラーレスポンスが一致しています。
$ curl --location 'http://localhost:8080/articles/012345678901234567890123456789013?limit=21&page-number=21' | jq .
{
"errors": {
"body": [
"page-numberは20以下にしてください",
"limitは20以下にしてください",
"slugは1文字以上32文字以下にしてください",
"offsetはnullが許可されていません"
]
}
}
このようにして、本記事が取り上げた ConstraintViolationException の課題を解決できました。
メリットは、エラーレスポンスがわかりやすくなることです。
デメリットは、API 仕様書を定義し、遵守する文化がないと仕様書とエラーレスポンスの乖離が発生しうることです。
今回はスネークケースでしたが、必要に応じて修正が必要です。
Web API: The Good Parts でも一般的決まったルールはなく、可能であれば単語続きにしないことが重要とのことです。
(中略、サービスごとの例を見て)
ウェブ上で公開されている意見を調べると URI については[1]のようにハイフンでつなぐのがよいという意見も多く見られます。しかしその理由を見てみると、Google がハイフンを推奨しており SEO 的によい、といったものが多くあまり API デザインにおいては関係がなさそうです。ではなぜ Google がハイフンを推奨しているかといえば、Google がハイフンは単語のつなぎとみなすが、アンダースコアは単に無視してひと続きの単語とみなしてしまうからだとか。さらに他の意見としてウェブページ上ではリンクアドレスに下線が引かれるがこれがアンダースコアと重なってしまうために見づらい、アンダースコアは歴史的にタイプライターで下線を引くためのものなので目的にそぐわない、といった話も見られます。実際にウェブページの URI はハイフンでつないだものが多く、たとえば WordPress の URI は記事のタイトルをベースにスパイナルケースで生成されます。しかし API の URI の設計において決定的な理由になりうるものはないようにも感じられます。したがってある程度は好みで決めてしまってよいのかなとも思いますが、迷った場合、あるいは特にポリシーがない場合はハイフンにしておくのがよいでしょう。理由はまず URI 中のホスト名(ドメイン名)はハイフンは許可されているもののアンダースコアは使えず、大文字小文字の区別がなく、ドットは特別な意味を持つため、ホスト名と同じルールで URI 全体を統一しようとするとハイフンでつなぐのが最も適していることになるからです。
ただし実際のところ最もよいのは単語をつなぎ合わせることを極力避けることです。たとえば popular_users とするのではなく users/popular とするなどパスとして区切る、一部をクエリパラメータにする、なるべく短い表現を目指すなどして単語をつなぐことを避けるほうが URI として見やすくなる場合が多いからです。
まとめ
ConstraintViolationException でバリデーションエラーが発生したパラメータを取得しようとしましたが、。
しかし、ConstraintViolationException から取得できなかったので、対処法を考えました。
本記事では、propertyPath をパースする方法を選択し実装しました。
しかし、リクエストから取得しているわけではないので、API 仕様からレスポンスが乖離する可能性に注意してください。
参考
Discussion