0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SpringBootでcommons-validatorのチェックを共通化してみよう

Last updated at Posted at 2024-07-25

はじめに

SpringBoot3、OpenAPI、commons-validator、kotlinでサーバサイドアプリを書いています。
RESTで送られてくるリクエストのパラメータをcommons-validatorでバリデーションチェック(入力値チェック)するのですが、パラメータの送信方法がいくつかあり、それぞれの方法でcommons-validatorでバリデーションチェックして結果を返す方法を紹介します。

そもそものHTTPの仕様

HTTPメソッド

HTTPのメソッドはRFC 7231で以下のメソッドが定義されています。

メソッド 意味
GET 指定したリソースを検索取得する
HEAD サーバが,応答内にメッセージ本体を送信してはならない(すなわち,応答は、ヘッダ節の終端で終了する) ことを除いて、GET と同じ
POST 指定したリソースを追加、登録する
PUT 指定したリソースを変更、置き換える
DELETE 指定したリソースを削除します。
CONNECT 対象リソースで識別されるサーバーとの間にトンネルを確立する
OPTIONS リソースに可用な通信オプションについての情報を要請する
PATCH 指定したリソースを部分的に変更する
TRACE 対象リソースへのパスに沿ってメッセージのループバックテストを実行します

一般的にGET=検索、取得、POST=登録、追加、PUT=更新、変更、DELETE=削除ではないでしょうか?
これはデータベースに対するCRUDとも一致します。
(PATCHは使い道がよーわからん)

パラメータの渡し方

これに対して、いくつかのパラメータの渡し方があります。

渡し方 説明 特徴、使い道 SpringBoot
Request Body データをリクエストのボディ部分に含めて送信します HTTP的にはRequestBodyの中身のフォーマットの規約はない。(XML、JSON、CSV何でもあり)長さに制限が緩いので大量データを送信する場合に使う @￰RequestBody
Query Parameter URLのクエリストリングとしてデータを送信します URLの後ろに?と&を使って引数を渡す。
長さに制限があるので大量データを送信するのには向かない
@￰RequestParam
Path Parameter URLの一部としてデータを送信します URLのパスの一部を引数として渡すURLの長さ制限がある @￰PathVariable

Request Bodyの例。リクエストの本文(body)を使って送ります。

POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john.doe@example.com"
}

Query Parameterの例。URLのパスの後ろに?と&で引数を区切って渡すます。

GET /api/users?name=JohnDoe&age=30 HTTP/1.1
Host: example.com

Path Parameterの例。URLのパスの一部(末尾)を引数に使います。

GET /api/users/123 
HTTP/1.1 Host: example.com

組み合わせ

これらHTTPメソッドとパラメータの渡し方の組み合わせになります。

メソッド Request Body Query Parameter Path Parameter
GET ×
POST
PUT
DELETE ※1
PATCH

GET、DELETE MethodのRequest Bodyは使えません。
※1 DELETEのRequest BodyはRFC 7231 (Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content)において、DELETEメソッドがリクエストボディを持つことを禁止はしていません。SpringBootでは受け取れます。ツール類やライブラリとの互換性を考えると使用するのは控えるべきです。

ここで困るのが、Query Parameter、Path ParameterがURLのパスの一部を使う都合上、長さ制限があることです。具体的な長さの制限はWebサーバ、ブラウザの制限、プロキシサーバ等の制限により一意ではありません。(一般的に8kとか4kとか・・・)

RFC 7230(HTTP/1.1 メッセージ構文とルーティング)には、具体的なURL長制限は定義されていませんが、バッファオーバランを防ぐため何らかの長さ制限が設けられているのが通常です。

RequestBodyも実際は無限ではなく、サーバ側のリソース消費を防ぐため、最大サイズが設けられているのが普通ですが、Query Parameter、Path Parameterに比べると遥かに大きいのが普通です。

GET、DELETEメソッドでその長さ制限に引っ掛かったとき、GETメソッドが使えず、POST、PUTを代わりに使わざるを得ません。折角めHTTP Methodがリソースに対する操作を表現しようとしているのに、そこだけ例外的に崩れてしまいます。

SpringBootでどう書く?

Method POST、RequestBodyの例

@RestController
class MyController(private val service: MyService) {

    @PostMapping("/myapi/regist")
    fun regist(@Valid @RequestBody request: MyRegistRequest): ResponseEntity<Unit> {

        ・・・・
        
    }
}

Controllerのメソッドに@￰PostMappingアノテーションを付けます。
メソッドの引数がdata classで @￰RequestBodyアノテーションを付けます。
@￰Validアノテーションはcommons-validatorでバリデーションチェックをする場合に付けます。
引数のdata classは以下のようになります。

@Schema(description = "登録リクエスト")
data class MyRegistRequest(
    @field:NotEmpty(message = "省略できません")
    @field:Pattern(regexp = "^[0-9]{5}$", message = "5桁の数字で入力してください")
    @field:JsonProperty("serialNo", required = true)
    @Schema(description = "シリアルNo", type = "string", example = "00001", required = true )
    var serialNo: String,

    @field:NotEmpty(message = "省略できません")
    @field:JsonProperty("deptCode", required = true)
    @field:Pattern(regexp = "^[0-9]{1,5}$", message = "1~5桁の数字で入力してください")
    @Schema(description = "部署コード", type = "string", example = "12345", required = true )
    var deptCode: String,

    ・・・・
}

ここで注意しなければならないのはkotlinのdata ckassの ( ) の中身はコンストラクタの引数でもあり、クラスのフィールドでもあります。
Javaの場合は別々ですが、これらのアノテーションはフィールドに対して指定するアノテーションです。kotlinだとフィールドなのかコンストラクタの引数なのかわからないので@￰field:で修飾してやる必要があります。

Method GET、QueryParameterの場合

@RestController
class MyController(private val service: MyService) {

    @Validated
    @GetMapping("myapi/search")
    fun search(
        @Valid
        @NotEmpty(message = "省略できません")
        @Pattern(regexp = "^[0-9]{5}$", message = "5桁の数字で入力してください")
        @Schema(description = "シリアルNo", type = "string", example = "00001", required = true )
        @RequestParam("serialNo") serialNo: String,

        @Valid
        @Pattern(regexp = "^[0-9]{1,3}$", message = "1~3桁の数字で入力してください")
        @Schema(description = "部署コード", type = "string", example = "123", required = false )
        @RequestParam("deptCode") deptCode: String?,

    ): ResponseEntity<MySearchResponse> {

       ・・・・
    }
}

Controllerのメソッドに@￰GetMappingアノテーションを付けます。
@￰Validatedアノテーションはcomons-validatorでバリデーションチェックを効かせるために必要です。
メソッドの引数1個、1個がQueryParameterの1個、1個に相当します。
メソッドの引数に対しても、comons-validatorでバリデーションチェックを効かせるため@￰Validアノテーションが必要です。
引数の型は上の例ではString型ですが、ここはInt型でも、LocalData型でも、配列でもSpringBootがよきに変換してくれるので受け取れます。(変換できないと400 BAD_REQUESTになる)

Method GET、PathParameterの場合

@RestController
class MyController(private val service: MyService) {

    @GetMapping("/myapi/search/{serialNo}")
    fun search(
            @Pattern(regexp = "^[0-9]{5}$", message = "5桁の数字で入力してください")
            @PathVariable("serialNo") serialNo: String
        ): ResponseEntity<MySearchResponse> {
           ・・・・
        }
}

URLのパスに末尾をパラメータとして使うので@￰GetMappingのパスの{serialNo}部分がパラメータになります。
Controllerのメソッドの引数に@￰PathVariableでこのパスの{serialNo}と一致するように名前を指定します。

commons-validatorでバリデーションチェック

上記の例で分かる通り、Request Body、Query Parameter、Path Parameterでcommons-validatorのバリデーションのアノテーションを使ってバリデーションチェックができます。

commons-validatorのバリデーションチェックで違反した場合、レスポンスに400 BAD_REQUESTが返ります。しかし、これだけではバリデーションチェックがたくさんあると、どのチェックに違反したのかクライアント側にはわかりません。

            @Pattern(regexp = "^[0-9]{5}$", message = "5桁の数字で入力してください")
            @PathVariable("serialNo") serialNo: String

この場合、クライアントには「serialNoは5桁の数字で入力してください」と返したいです。
複数ある場合は全部、配列で返したいです。

commons-validatorのバリデーションチェックで違反して、レスポンスに400 BAD_REQUESTの場合だけ、ResponseBodyに以下のようにJSONでエラーメッセージを返すようにします。

{
  "errors": {
    "body": [
      "〇〇は〇〇です", "△△は△△です"
    ]
  }
}

このResponseBodyにJSONを返すためのdata classです。

import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.Valid

data class ValidationError(
    @field:Valid
    @field:JsonProperty("errors", required = true)
    @Schema(required = true, description = "")
    val errors: ValidationErrors
)
data class ValidationErrors(
    @field:JsonProperty("body", required = true)
    @Schema(required = true, description = "")
    val body: List<String>,
)

各Controllerの各メソッドの戻り値でResponseBodyの型が決まっています。なのでControllerのメソッドの中から400 BAD_REQUESTの時だけ違うResponseBodyを返すということはできません。

    @GetMapping("/myapi/search/{serialNo}")
    fun search(
           ・・・・
        ): ResponseEntity<MySearchResponse> {
           ・・・・
        }
}

commons-validatorのバリデーションチェックで違反した場合、ContRollerから例外がthrowされるので、それを@￰RestControllerAdviceでcatchして、ResponseBodyを作り、それを返します。
それぞれ、バリデーションチェックで違反した場合、throwされる例外は以下のようになります。

パラメータの渡し方 例外
Request Body MethodArgumentNotValidException または HandlerMethodValidationException (※)
Request Parameter MethodArgumentNotValidException または HandlerMethodValidationException (※)
Path parameter ConstraintViolationException

(※)古いSpringのバージョンの情報ではConstraintViolationException が返るという情報がありますが、SpringFrameWorkの公式ページでは

@ModelAttribute、@RequestBody、@RequestPart 引数リゾルバーは、メソッドパラメーターに Jakarta @Valid または Spring の @Validated がアノテーション付けされており、直後に Errors または BindingResult パラメーターがなく、メソッド検証が不要 (次に説明) である場合に、メソッド引数を個別に検証します。この場合に発生する例外は MethodArgumentNotValidException です。

@Min、@NotBlank などの @Constraint アノテーションがメソッドパラメーターに直接宣言されている場合、またはメソッド (戻り値用) に宣言されている場合、メソッド検証を適用する必要があり、メソッド検証はメソッドパラメーター制約と @Valid を介したネストされた制約の両方をカバーするため、メソッド引数レベルでの検証よりも優先されます。この場合に発生する例外は HandlerMethodValidationException です。

アプリケーションは、コントローラーメソッドシグネチャーに応じて MethodArgumentNotValidException と HandlerMethodValidationException のいずれかが発生する可能性があるため、両方を処理する必要があります。ただし、この 2 つの例外は非常によく似た設計になっており、ほぼ同じコードで処理できます。主な違いは、前者は単一のオブジェクト用であり、後者はメソッドパラメーターのリスト用である点です。

と、あります。つまり、Validatorの種類によって、 MethodArgumentNotValidException または HandlerMethodValidationExceptionの両方がthrowされる可能性があります。なので両方の例外の処理が必要になります。

@￰Min、@￰NotBlank などの @￰Constraint アノテーション(を内包しているアノテーション?って意味?)メソッド検証が必要になるので、MethodArgumentNotValidExceptionがthrowされます。

バリデーションチェック違反の例外をcatchしてクライアントに返すメッセージを処理するクラスを作ります。それぞれの例外でバリデーションのエラーメッセージの取得の仕方が違います。

import jakarta.validation.ConstraintViolationException
import jp.co.suzuken.webapi.common.exception.CustomValidationException
import jp.co.suzuken.webapi.common.model.ValidationError
import jp.co.suzuken.webapi.common.model.ValidationErrors
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.HandlerMethodValidationException

@RestControllerAdvice
class ValidationExceptionHandleController {
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun methodArgumentNotValidExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity<ValidationError> {
        val messages = e.bindingResult.allErrors.map {
            when (it) {
                is FieldError -> "${it.field}は${it.defaultMessage}"
                else -> it.defaultMessage.toString()
            }
        }
        return ResponseEntity<ValidationError>(
            ValidationError(
                errors = ValidationErrors(body = messages)
            ),
            HttpStatus.BAD_REQUEST
        )
    }

    @ExceptionHandler(HandlerMethodValidationException::class)
    fun methodValidationExceptionHandler(e: HandlerMethodValidationException): ResponseEntity<ValidationError> {
        val messages = e.valueResults.flatMap { result ->
            val field = result.methodParameter.parameterName
            result.resolvableErrors.map { err ->
                "${field}は${err.defaultMessage}"
            }
        }
        return ResponseEntity(
            ValidationError(
                errors = ValidationErrors(body = messages)
            ),
            HttpStatus.BAD_REQUEST
        )
    }

    @ExceptionHandler(ConstraintViolationException::class)
    fun constraintViolationExceptionHandler(e: ConstraintViolationException): ResponseEntity<ValidationError> {
        val messages = e.constraintViolations.map {
            val requestParam = it.propertyPath.toString()
                .split('.').last()
            "${requestParam}は${it.message}"
        }
        return ResponseEntity(
            ValidationError(
                errors = ValidationErrors(body = messages)
            ),
            HttpStatus.BAD_REQUEST
        )
    }
}

これで、Request Body、Query Parameter、Path Parameterでcommons-validatorのバリデーション違反があった場合、HTTPレスポンスは400 BAD_REQUEST、ResponseBodyに

{
  "errors": {
    "body": [
      "〇〇は〇〇です", "△△は△△です"
    ]
  }
}

が返るようになります。めでたし、めでたし。

もうちょっと複雑なバリデーションチェック

例えば、こういうケースが考えられます

  • 開始番号
  • 終了番号

検索条件等でよくある、番号の範囲で検索したい場合、開始番号、終了番号の両方を指定する(非null)か両方未指定(null)かどちらかしか許容しない。片方はダメとします。

上のcommons-validatorの例では基本フィールド1個単位でのバリデーションチェックなので、このような複数フィールド間のバリデーションチェックはできません。

RequestBodyの場合はControllerのメソッドの引数のdata classで以下のように書くことで複数項目間のチェックをcommons-validatorでできます。

data class MyRequest (
    @field:Digits(integer = 5, fraction = 0, message = "5桁の数字で入力してください")
    @field:JsonProperty("startNo", required = false)
    @Schema(description = "開始番号", type = "integer", example = "100", required = false )
    var startNo: Int?,

    @field:Digits(integer = 5, fraction = 0, message = "5桁の数字で入力してください")
    @field:JsonProperty("endNo", required = false)
    @Schema(description = "終了番号", type = "integer", example = "100", required = false )
    var endNo: Int?,
) {
    @Schema(hidden = true, required = false, deprecated = true)
    @AssertTrue(message = "開始番号と終了番号の両方を指定してください")
    fun isBoth(): Boolean {
        return (startNo == null && endNo == null) || (startNo != null && endNo != null)
    }
}

@￰AsserTrueはtrueを返すこと、それがチェック正常。@￰AssertFalseはfalseを返すこと、それがチェック正常という意味になります。
上の例ではisBoth()がtrueを返せばチェック正常、falseの場合はバリデーション違反なので「開始番号と終了番号の両方を指定してください」というメッセージを含んで例外がthrowされます。あとは、@￰RestControllerAdviceでcatchしてResponseBodyを作って返すのは同じです。

上の例は2フィールド間のバリデーションチェックですが、要はisBoth()がtrueを返すか、falseを返すかなので3フィールド間以上のバリデーションチェックでも応用ができます。

RequestBodyの場合は引数がdata classなのでこういったやりかたが可能ですが、Query Parameterだと、Controllerのメソッドの引数が1個づつバラバラなのでできません。

commons-validatorのカスタムvalidatorを作ります。

IntFieldPair.kt
import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass

/**
 * Intのフィールドのペアのバリデーションアノテーション
 * 両方がnullか、両方がnullでない場合にバリデーションを通過する
 */
@Target(AnnotationTarget.CLASS, AnnotationTarget.FILE)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [LocalDateFieldsPairValidator::class])
annotation class IntFieldsPair(
    val message: String = "両方の項目を入力するか、両方の項目を省略してください",
    @Suppress("unused")
    val groups: Array<KClass<*>> = [],
    @Suppress("unused")
    val payload: Array<KClass<out Payload>> = []
)

class IntFieldsPairValidator : ConstraintValidator<IntFieldsPair, IntValidatePair> {
    override fun isValid(value: IntValidatePair?, context: ConstraintValidatorContext?): Boolean {
        if (value == null) return true
        return (value.fieldA == null && value.fieldB == null) || (value.fieldA != null && value.fieldB != null)
    }
}
/** 比較するIntフィールド */
data class IntValidatePair(
    val fieldA: Int?,
    val fieldB: Int?
)

Controller側ではこのように書きます。

@RestController
class MyController(private val service: MyService) {

    @Validated
    @GetMapping(LIST_SEARCH_PATH)
    fun search(
        @Valid
        @Digits(integer = 5, fraction = 0, message = "5桁の数字で入力してください")
        @Schema(description = "開始番号", type = "integer", example = "100", required = false )
        @RequestParam("startNo") startNo: Int?,

        @Valid
        @Digits(integer = 5, fraction = 0, message = "5桁の数字で入力してください")
        @Schema(description = "終了番号", type = "integer", example = "100", required = false )
        @RequestParam("endNo") endNo: Int?,
    ): ResponseEntity<OrderingSearchResponse> {
        // 2項目間のバリデーションチェック
        val numPair = IntValidatePair(startNo, endNo)
        val myValidator = IntFieldsPairValidator()
        if (!myValidator.isValid(numPair, null)) {
            throw CustomValidationException(listOf("開始番号と終了番号は両方入力するか両方未入力にしてください"))
        }
        ・・・
    }
}

例外クラスを1個作ります。バリデーションチェック違反の場合のメッセージは複数件を想定してListにします。

CustomValidationException.kt
class CustomValidationException : RuntimeException {

    var messages: List<String> = emptyList()

    @Suppress("unused")
    constructor(mess: String) : super(mess)

    constructor(messages: List<String>): super(messages.joinToString(", ")) {
        this.messages = messages
    }
}

@￰RestControllerAdviceでバリデーションチェック違反の例外をcatchするメソッドを追加します。

ValidationExceptionHandleController.kt
@RestControllerAdvice
class ValidationExceptionHandleController {

   ・・・

    @ExceptionHandler(CustomValidationException::class)
    fun customViolationExceptionHandler(e: CustomValidationException): ResponseEntity<ValidationError> {
        return ResponseEntity(
            ValidationError(
                errors = ValidationErrors(body = e.messages)
            ),
            HttpStatus.BAD_REQUEST
        )
    }
}

これでQuery Parameterで2フィールド間のバリデーションチェック違反も、他の標準のバリデーションチェックと同じ処理でクライアントにResponseBodyにバリデーションチェックエラーメッセージが返ります。

IntFieldPair.ktはInt型で2個のフィールドの例ですが、これを応用してString型、LocalDate型・・・、3フィールド以上等のカスタムvalidatorを作ることもできます。

最後に

SpringBootでcommons-validatorの使い方の例でした。
ご意見、コメント等あれば、よろしくお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?