JAX-RSとバリデーションを合わせて使ったことないなぁと思い、ちょっと試してみることに。
アプリケーションサーバはWildFly、JAX-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