CLOVER🍀

That was when it all began.

JAX-RS(RESTEasy)とBean Validationを合わせて使う

JAX-RSとバリデーションを合わせて使ったことないなぁと思い、ちょっと試してみることに。

アプリケーションサーバWildFlyJAX-RSの実装はRESTEasyです。

参考にしたのは、こちらです。

Validating JAX-RS resource data with Bean Validation in Java EE 7 and WildFly
http://www.samaxes.com/2014/04/jaxrs-beanvalidation-javaee7-wildfly/

Java / JAX-RS / Bean Validation 勉強メモ
http://www.glamenv-septzen.net/view/1288

JSR-000339 JAX-RS 2.0 Specification(の7.6 Validation and Error Reporting)
http://download.oracle.com/otndocs/jcp/jaxrs-2_0-fr-eval-spec/index.html

とりあえず、適当にバリデーションを行うJAX-RSリソースクラスを作って、動作確認してみましょう。

あ、WildFlyは8.1.0.Finalを使用していますが、すでに起動しているものとします。また、デプロイのところとかは省略します。

準備

依存関係の定義。
build.sbt

name := "jaxrs-bean-validation"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.11.2"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked", "-feature")

incOptions := incOptions.value.withNameHashing(true)

jetty()

artifactName := { (version: ScalaVersion, module: ModuleID, artifact: Artifact) =>
  //artifact.name + "." + artifact.extension
  "javaee7-web." + artifact.extension
}

val jettyVersion = "9.2.3.v20140905"

libraryDependencies ++= Seq(
  "org.eclipse.jetty" % "jetty-webapp" % jettyVersion % "container",
  "org.eclipse.jetty" % "jetty-plus"   % jettyVersion % "container",
  "javax" % "javaee-web-api" % "7.0" % "provided",
  "org.jboss.resteasy" % "resteasy-jaxrs" % "3.0.8.Final" % "provided",
  "org.jboss.resteasy" % "resteasy-validator-provider-11" % "3.0.8.Final" % "provided"
)

ホントはRESTEasy本体への依存関係は不要だったはずなのですが、後半でExceptionMapperを書く際に必要に…。

WARファイルの名前は、「javaee7-web.war」とします。

project/plugins.sbt

addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "1.0.0-M6")

xsbt-web-pluginも使用します。

JAX-RS関連のクラスの作成

まずはApplicationクラスのサブクラス。
src/main/scala/org/littlewings/javaee7/validation/rest/JaxrsApplication.scala

package org.littlewings.javaee7.validation.rest

import javax.ws.rs.ApplicationPath
import javax.ws.rs.core.Application

@ApplicationPath("rest")
class JaxrsApplication extends Application

パスは「rest」とします。

リソースクラス。
src/main/scala/org/littlewings/javaee7/validation/rest/ValidationResource.scala

package org.littlewings.javaee7.validation.rest

import javax.validation.Valid
import javax.validation.constraints.{Digits, NotNull}
import javax.ws.rs.{Consumes, DefaultValue, GET, Path, Produces, POST, QueryParam}
import javax.ws.rs.core.MediaType

@Path("validation")
class ValidationResource {
  /* メソッド定義 */
}

それでは、簡単にメソッドを定義してみます。

  @GET
  @Path("simple")
  @Produces(Array(MediaType.TEXT_PLAIN))
  def simple(@QueryParam("name") @NotNull name: String,
             @QueryParam("num") @DefaultValue("0") @Digits(integer = 3, fraction = 0) num: String): String =
    s"name = $name, num = $num${System.lineSeparator}"

QueryStringで、「name」と「num」を受け取って、そのまま結果として返すメソッドです。

試してみます。

## OKなケース
$ curl -i 'http://localhost:8080/javaee7-web/rest/validation/simple?name=Kazuhira'
HTTP/1.1 200 OK
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
Content-Type: text/plain
Content-Length: 25
Date: Sat, 13 Sep 2014 11:53:53 GMT

name = Kazuhira, num = 0

=====================
## OKなケース
$ curl -i 'http://localhost:8080/javaee7-web/rest/valida
tion/simple?name=Kazuhira&num=100'
HTTP/1.1 200 OK
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
Content-Type: text/plain
Content-Length: 27
Date: Sat, 13 Sep 2014 11:54:09 GMT

name = Kazuhira, num = 100

=====================
## NGなケース
$ curl -i 'http://localhost:8080/javaee7-web/rest/valida
tion/simple?num=100'
HTTP/1.1 400 Bad Request
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
validation-exception: true
Content-Type: text/plain
Content-Length: 52
Date: Sat, 13 Sep 2014 11:54:41 GMT

なるほど、バリデーションでNGになった場合は、Bad Requestですか。

では、今度はリクエストとレスポンスをJSONとするようにしてみましょう。まずはリクエストを受けるためのクラス。
src/main/scala/org/littlewings/javaee7/validation/rest/Param.scala

package org.littlewings.javaee7.validation.rest

import scala.beans.BeanProperty

import javax.validation.Valid
import javax.validation.constraints.{Digits, NotNull, Size}

class Param {
  @NotNull
  @BeanProperty
  var name: String = _

  @Digits(integer = 3, fraction = 0)
  @BeanProperty
  var num: String = _

  @Valid
  @Size(min = 1)
  @BeanProperty
  var subs: java.util.List[Sub] = _
}

class Sub {
  @NotNull
  @BeanProperty
  var value: String = _
}

ネストする場合で、ネスト先もバリデーション対象にしたい場合は@Validアノテーションを付与すればよいみたいです。

  @Valid
  @Size(min = 1)
  @BeanProperty
  var subs: java.util.List[Sub] = _

リソースクラス側のメソッド定義。

  @POST
  @Path("complex")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def complex(@Valid param: Param): Res =
    Res(param.name, Some(param.num).getOrElse("0").toInt, param.subs)

基本的には、受け取ったものをそのまま返すような実装です。ポイントは、ここでも@Validアノテーションが付与されていることでしょうか。

  def complex(@Valid param: Param): Res =

なんとなく用意した、結果用のクラス。

src/main/scala/org/littlewings/javaee7/validation/rest/Res.scala 
package org.littlewings.javaee7.validation.rest

import scala.beans.BeanProperty

object Res {
  def apply(name: String, num: Int, subs: java.util.List[Sub]): Res = {
    val res = new Res
    res.name = name
    res.num = num
    res.subs = subs
    res
  }
}

class Res {
  @BeanProperty
  var name: String = _
  @BeanProperty
  var num: Int = _
  @BeanProperty
  var subs: java.util.List[Sub] = _
}

では、試してみます。

## OKなケース
$ curl -i -H 'Content-Type: application/json' -X POST -d '{"name": "Kazuhira", "num": "100", "subs": [{"value": "hoge"}] }' http://localhost:8080/javaee7-web/rest/validation/complex
HTTP/1.1 200 OK
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
Transfer-Encoding: chunked
Content-Type: application/json
Date: Sat, 13 Sep 2014 11:58:32 GMT

{"name":"Kazuhira","num":100,"subs":[{"value":"hoge"}]}

==============
## NGなケース
$ curl -i -H 'Content-Type: application/json' -X POST -d '{"name": "Kazuhira", "num": "1000000", "subs": [] }' http://localhost:8080/javaee7-web/rest/validation/complex
HTTP/1.1 400 Bad Request
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
validation-exception: true
Content-Type: text/plain
Content-Length: 235
Date: Sat, 13 Sep 2014 11:58:50 GMT

やっぱり、NGな場合はBad Requestですね。

最初の使い方としては、こんなところでしょうか。

ExceptionMapperを書く

さすがにResponseがBad Requestと表現するだけでは寂しすぎるので、何かしらメッセージなりを返したいところです。こういう挙動のカスタマイズには、ExceptionMapperインターフェースを実装したクラスを作成すればよいみたい?

というわけで、作ってみました。
src/main/scala/org/littlewings/javaee7/validation/rest/ValidationExceptionMapper.scala

package org.littlewings.javaee7.validation.rest

import scala.annotation.tailrec
import scala.collection.JavaConverters._

import java.text.MessageFormat

import javax.validation.{ConstraintViolationException, Validation, ValidationException}
import javax.ws.rs.core.{MediaType, Response}
import javax.ws.rs.ext.{ExceptionMapper, Provider}

import org.jboss.resteasy.api.validation.ResteasyConstraintViolation
import org.jboss.resteasy.api.validation.ResteasyViolationException

@Provider
class ValidationExceptionMapper extends ExceptionMapper[ValidationException] {
  override def toResponse(e: ValidationException): Response =
    e match {
      case rve: ResteasyViolationException =>
        Response
          .status(Response.Status.BAD_REQUEST)
          .entity(Map("messages" -> rve
                                      .getViolations
                                      .asScala
                                      .map { v => MessageFormat.format(v.getMessage, v.getPath) }
                                      .asJava).asJava)
          .`type`(MediaType.APPLICATION_JSON)  // またはResteasyViolationException#getAccept
          .build
      case cve: ConstraintViolationException =>
        Response
          .status(Response.Status.BAD_REQUEST)
          .entity(Map("messages" -> cve.getConstraintViolations).asJava)
          .`type`(MediaType.APPLICATION_JSON)
          .build
      case _ =>
        Response
          .serverError
          .entity(exceptionAsString(e))
          .header("validation-exception", "true")
          .`type`(MediaType.TEXT_PLAIN)
          .build
    }

  private def exceptionAsString(e: Exception): String =
    e match {
      case null => ""
      case _ =>
        (Vector(s"${e.getClass.getName}")
          ++ toStackTrace(e)
          ++ causeWhile(e, Vector.empty))
          .mkString(System.lineSeparator)
    }

  @tailrec
  private def causeWhile(th: Throwable, acc: Vector[String]): Vector[String] =
    th.getCause match {
      case null => acc
      case cause =>
        causeWhile(cause, ((acc :+ s"Cause by: ${cause.getMessage}") ++ toStackTrace(cause)))
    }

  private def toStackTrace(th: Throwable): Vector[String] =
    th.getStackTrace.foldLeft(Vector.empty[String]) {
      (acc, elm) => acc :+ s" at ${elm.toString}"
    }
}

なんか、ムダに長いです(笑)。

JAX-RSで例外発生時に、ExceptionMapperの型パラメータに対して指定した型がスローされた場合に、呼び出されるようで?

@Provider
class ValidationExceptionMapper extends ExceptionMapper[ValidationException] {

@Providerアノテーションもお忘れなく。

ValidationExceptionは、Bean Validationの例外ですが、そのサブクラスも存在するようです。JAX-RSのJSR-339を見ると、以下のようなことらしいです(7.6 Validation and Error Reportingより)

  • 発生した例外が、ValidationException、またはConstraintViolationException以外のValidationExceptionのサブクラスの場合は、ステータスコード500にマップされる
  • ConstraintViolationExceptionがバリデーションメソッドの戻り値だった場合は、ステータスコード500にマップされる
  • それ以外のケースで、ConstraintViolationExceptionがスローされた場合は、ステータスコード400にマップされる

というわけで、バリデーションでエラーになった場合は400が返る、というわけですね。

なので、ConstraintViolationException以外のValidationExceptionはアノテーションの付与が間違っていた場合や何らかのエラーが発生した場合のものなので、バリデーションエラーを扱いたければConstraintViolationExceptionに気を払えばよい、ということになります。

ところが、RESTEasyの場合はResteasyViolationExceptionという例外が飛んでくるのですが、これはValidationExceptionのサブクラスではあるのですが、ConstraintViolationExceptionとは継承関係がありません。ですので、ちょっと特別扱いが必要です…。

で、結果こうなりましたよっと。

  override def toResponse(e: ValidationException): Response =
    e match {
      case rve: ResteasyViolationException =>
        Response
          .status(Response.Status.BAD_REQUEST)
          .entity(Map("messages" -> rve
                                      .getViolations
                                      .asScala
                                      .map { v => MessageFormat.format(v.getMessage, v.getPath) }
                                      .asJava).asJava)
          .`type`(MediaType.APPLICATION_JSON)  // またはResteasyViolationException#getAccept
          .build
      case cve: ConstraintViolationException =>
        Response
          .status(Response.Status.BAD_REQUEST)
          .entity(Map("messages" -> cve.getConstraintViolations).asJava)
          .`type`(MediaType.APPLICATION_JSON)
          .build
      case _ =>
        Response
          .serverError
          .entity(exceptionAsString(e))
          .header("validation-exception", "true")
          .`type`(MediaType.TEXT_PLAIN)
          .build
    }

ResteasyViolationExceptionまたはConstraintViolationExceptionの場合は、JSONレスポンスを設定しています。それ以外の場合は、スタックトレースを「text/plain」で返すように実装しました。

なお、ResteasyViolationExceptionの場合はgetAcceptメソッドでMediaTypeが取得できるみたいですね。

あと、メッセージファイルもせっかくなので日本語化しました。
src/main/resources/ValidanMessages.properties.utf8

javax.validation.constraints.AssertFalse.message = {0} は false でなければなりません
javax.validation.constraints.AssertTrue.message  = {0} は true でなければなりません
javax.validation.constraints.DecimalMax.message  = {0} は {value} ${inclusive == true ? '以下でなければ' : 'よりも小さくなければ'}いけません
javax.validation.constraints.DecimalMin.message  = {0} は {value} ${inclusive == true ? '以上でなければ' : 'よりも小さくなければ'}いけません
javax.validation.constraints.Digits.message      = {0}  は数値 (<整数 {integer} 桁>.<小数 {fraction} 桁>)でなければいけません
javax.validation.constraints.Future.message      = {0} は未来の値でなければいけません
javax.validation.constraints.Max.message         = {0} は {value} 以下でなければいけません
javax.validation.constraints.Min.message         = {0} は {value} 以上でなければいけません
javax.validation.constraints.NotNull.message     = {0} は必須です
javax.validation.constraints.Null.message        = {0} は null でなければいけません
javax.validation.constraints.Past.message        = {0} は過去の値でなければいけません
javax.validation.constraints.Pattern.message     = {0} はパターン "{regexp}" にマッチしなければいけません
javax.validation.constraints.Size.message        = {0} のサイズは {min} と {max} の間でなければいけません

MessageFormatの書式がありますが、先ほどの自作のExceptionMapperの中で自分でMessageFormat#format呼んでます…。

これをnative2asciiかけて、WARにパッケージングして再デプロイします。

そして、先ほどcurlで行った動作確認を、もう1度試してみます。

## OKなケース
$ curl -i -H 'Content-Type: application/json' -X POST -d '{"name": "Kazuhira", "num": "100", "subs": [{"value": "hoge"}] }' http://localhost:8080/javaee7-web/rest/validation/complex
HTTP/1.1 200 OK
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
Transfer-Encoding: chunked
Content-Type: application/json
Date: Sat, 13 Sep 2014 12:27:11 GMT

{"name":"Kazuhira","num":100,"subs":[{"value":"hoge"}]}

=========================
## NGなケース
$ curl -i -H 'Content-Type: application/json' -X POST -d '{"name": "Kazuhira", "num": "1000000", "subs": [] }' http://localhost:8080/javaee7-web/rest/validation/complex
HTTP/1.1 400 Bad Request
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/8
Transfer-Encoding: chunked
Content-Type: application/json
Date: Sat, 13 Sep 2014 12:27:52 GMT

{"messages":["complex.arg0.num  は数値 (<整数 3 桁>.<小数 0 桁>)でなければいけません","complex.arg0.subs のサイズは 1 と 2147483647 の間でなければいけません"]}

項目名は、もういいやということで…。とりあえず、大丈夫そうですね。

今回作成したコードは、こちらに置いています。
https://github.com/kazuhira-r/javaee7-scala-examples/tree/master/jaxrs-bean-validation