CLOVER🍀

That was when it all began.

Bean ValidationのConvertGroupを試してみる

そろそろ区切りにしようかなと思う、Bean Validationネタ。今回は、@ConvertGroupを試してみました。

Group conversion
http://docs.jboss.org/hibernate/validator/5.1/reference/en-US/html_single/#section-group-conversion

これを使うと、バリデーション時にGroupの変換ができる(違うGroupを指定したことにできる)みたいです。@Validと一緒に使うのだとか。

試してみましょう。

準備

ビルド定義。
build.sbt

name := "bean-validation-group-conversion"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.11.7"

organization := "org.littlewings"

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

updateOptions := updateOptions.value.withCachedResolution(true)

fork in Test := true

val tomcatVersion = "8.0.23"
val resteasyVersion = "3.0.11.Final"
val weldServletVersion = "2.2.13.Final"
val scalaTestVersion = "2.2.5"

libraryDependencies ++= Seq(
  "org.apache.tomcat.embed" % "tomcat-embed-core" % tomcatVersion,
  "org.apache.tomcat.embed" % "tomcat-embed-jasper" % tomcatVersion,
  "org.apache.tomcat.embed" % "tomcat-embed-logging-juli" % tomcatVersion,
  "org.jboss.resteasy" % "resteasy-servlet-initializer" % resteasyVersion,
  "org.jboss.resteasy" % "resteasy-cdi" % resteasyVersion,
  "org.jboss.resteasy" % "resteasy-validator-provider-11" % resteasyVersion,
  "org.jboss.resteasy" % "resteasy-jackson2-provider" % resteasyVersion,
  "org.jboss.resteasy" % "resteasy-client" % resteasyVersion % "test",
  "org.jboss.weld.servlet" % "weld-servlet" % weldServletVersion,
  "org.scalatest" %% "scalatest" % scalaTestVersion % "test"
)

今回は、Java SEでBean Validationを使うのではなく、JAX-RS(RESTEasy)と合わせてみました。実行は、組み込みTomcatで行います。…ムダにCDI入り。

beans.xmlも置いておきます。
src/main/resources/META-INF/beans.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                           http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
       bean-discovery-mode="annotated">
</beans>

テストコードとJAX-RSで使うコードの用意

テストに、組み込みTomcatJAX-RSを使います。まずはJAX-RSの有効化。
src/main/scala/org/littlewings/javaee7/rest/JaxrsApplication.scala

package org.littlewings.javaee7.rest

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

@ApplicationPath("rest")
class JaxrsApplication extends Application

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

package org.littlewings.javaee7.rest

import javax.enterprise.context.RequestScoped
import javax.validation.Valid
import javax.validation.groups.{ConvertGroup, Default}
import javax.ws.rs.core.MediaType
import javax.ws.rs.{POST, Path, Produces}

import org.littlewings.javaee7.beanvalidation.{GroupA, User, User2}

@Path("validation")
@RequestScoped
class ValidationResource {
  // 後で
}

リソースクラスの中身は後で書きます。

テスト時に、組み込みTomcatを起動するTrait。
src/test/scala/org/littlewings/javaee7/beanvalidation/EmbeddedTomcatCdiSupport.scala

package org.littlewings.javaee7.beanvalidation

import java.io.File

import org.apache.catalina.startup.Tomcat
import org.scalatest.{BeforeAndAfterAll, Suite}

trait EmbeddedTomcatCdiSupport extends Suite with BeforeAndAfterAll {
  protected val port: Int = 8080
  protected val tomcat: Tomcat = new Tomcat
  protected val baseDir: File = createTempDir("tomcat", port)
  protected val docBaseDir: File = createTempDir("tomcat-docbase", port)

  override def beforeAll(): Unit = {
    tomcat.setPort(port)

    tomcat.setBaseDir(baseDir.getAbsolutePath)

    val context =
      tomcat.addWebapp("", docBaseDir.getAbsolutePath)

    context.addParameter("org.jboss.weld.environment.servlet.archive.isolation", "false")
    context.addParameter("resteasy.injector.factory", "org.jboss.resteasy.cdi.CdiInjectorFactory")

    tomcat.start()
  }

  override def afterAll(): Unit = {
    tomcat.stop()
    tomcat.destroy()

    deleteDirs(baseDir)
    deleteDirs(docBaseDir)
  }

  private def createTempDir(prefix: String, port: Int): File = {
    val tempDir = File.createTempFile(s"${prefix}.", s".${port}")
    tempDir.delete()
    tempDir.mkdir()
    tempDir.deleteOnExit()
    tempDir
  }

  private def deleteDirs(file: File): Unit = {
    file
      .listFiles
      .withFilter(f => f.getName != "." && f.getName != "..")
      .foreach {
      case d if d.isDirectory => deleteDirs(d)
      case f => f.delete()
    }

    file.delete()
  }
}

テストコードの雛形。
src/test/scala/org/littlewings/javaee7/beanvalidation/ConvertGroupSpec.scala

package org.littlewings.javaee7.beanvalidation

import javax.ws.rs.client.{ClientBuilder, Entity}
import javax.ws.rs.core.Response

import org.scalatest.FunSpec
import org.scalatest.Matchers._

class ConvertGroupSpec extends FunSpec with EmbeddedTomcatCdiSupport {
  describe("ConvertGroup Spec") {
    // ここに、テストを書く!
  }
}

テストは、JAX-RS Client APIを使います。

@ConvertGroupを使ったコードを書く

それでは、@ConvertGroupを使ったコードを書いてみます。

今回は、ネストしたJavaBeansに対してGroupを切り替えていくことを考えます。

ここでは、以下のようなGroupおよびDefaultを使っていきます。
src/main/scala/org/littlewings/javaee7/beanvalidation/Groups.scala

package org.littlewings.javaee7.beanvalidation

trait GroupA

trait GroupB

trait GroupC
普通にJavaBeansを定義してみる

まずは、JavaBeans内では@ConvertGroupを使わないパターンで書いてみます。

まあ、単純なケースですね。
src/main/scala/org/littlewings/javaee7/beanvalidation/User.scala

package org.littlewings.javaee7.beanvalidation

import javax.validation.Valid
import javax.validation.constraints.{Max, Pattern}
import javax.validation.groups.{ConvertGroup, Default}

import scala.beans.BeanProperty

class User {
  @BeanProperty
  @Valid
  var name: Name = _

  @BeanProperty
  @Max.List(
    Array(
      new Max(value = 12),
      new Max(value = 32, groups = Array(classOf[GroupA]))
    )
  )
  var age: Int = _
}

class Name {
  @BeanProperty
  @Pattern.List(
    Array(
      new Pattern(regexp = "^(カツオ|ワカメ)$"),
      new Pattern(regexp = "^(サザエ|マスオ|タラオ)$", groups = Array(classOf[GroupA]))
    )
  )
  var first: String = _

  @BeanProperty
  @Pattern.List(
    Array(
      new Pattern(regexp = "^磯野$"),
      new Pattern(regexp = "^フグ田$", groups = Array(classOf[GroupA]))
    )
  )
  var last: String = _
}

それぞれ、DefaultとGroupAに対して、バリデーションを定義しています。

ちなみに、こういうのは

  @Max.List(
    Array(
      new Max(value = 12),
      new Max(value = 32, groups = Array(classOf[GroupA]))
    )
  )

Javaで書くとこういう意味になります。

  @Max.List({@Max(value = 12), @Max(value = 32, groups = {GroupA.class})})

Scalaアノテーションがネストする場合、書き方がちょっと…。

では、これに対するJAX-RSリソースクラス側の定義。

  @Path("default")
  @POST
  @Produces(Array(MediaType.APPLICATION_JSON))
  def defaultGroup(@Valid user: User): User =
    user

@Valid付き。

この場合、ネストしたJavaBeansも含めてDefaultとしてバリデーションされます。

    it("Default Group, valid") {
      val user = new User
      user.name = new Name
      user.name.first = "カツオ"
      user.name.last = "磯野"
      user.age = 12

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/default")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.OK.getStatusCode)
        val responseEntity = response.readEntity(classOf[User])
        responseEntity.name.first = "カツオ"
        responseEntity.name.last = "磯野"

        response.close()
      } finally {
        client.close()
      }
    }

これはDefaultとしてはNG(lastnameが「磯野」じゃない)。

      val user = new User
      user.name = new Name
      user.name.first = "カツオ"
      user.name.last = "磯野?"
      user.age = 12

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/default")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.BAD_REQUEST.getStatusCode)

これもDefaultとしてはNG(ageが大きい)。

      val user = new User
      user.name = new Name
      user.name.first = "カツオ"
      user.name.last = "磯野"
      user.age = 20

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/default")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.BAD_REQUEST.getStatusCode)

では、JAX-RSリソース側で@ConvertGroupを付与するようにしてみます。

  @Path("convertGroupA")
  @POST
  @Produces(Array(MediaType.APPLICATION_JSON))
  def convertGroupA(@Valid @ConvertGroup(from = classOf[Default], to = classOf[GroupA]) user: User): User =
    user

@Validは、JAX-RSでの利用に限らず、合わせて使うことになるみたいです。

すると、バリデーションの内容がGroupAに切り替わります。

      val user = new User
      user.name = new Name
      user.name.first = "サザエ"
      user.name.last = "フグ田"
      user.age = 24

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/convertGroupA")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.OK.getStatusCode)
        val responseEntity = response.readEntity(classOf[User])
        responseEntity.name.first = "サザエ"
        responseEntity.name.last = "フグ田"

        response.close()
      } finally {
        client.close()
      }

全部書くと長くなるので、NGのケースは省略します…。

ネストしたJavaBeansにも、@ConvertGroupをつけてみる

続いて、ネストしたJavaBeansにも@ConvertGroupをつけてみます。ネストしたJavaBeansに付与されたGroupが変えられないとか、呼び出し元に意識して欲しくないとかそういう時に使うといいのかな?

class User2 {
  @BeanProperty
  @Valid
  @ConvertGroup.List(
    Array(
      new ConvertGroup(from = classOf[Default], to = classOf[GroupB]),
      new ConvertGroup(from = classOf[GroupA], to = classOf[GroupC])
    )
  )
  var name: Name2 = _

  @BeanProperty
  @Max.List(
    Array(
      new Max(value = 12),
      new Max(value = 32, groups = Array(classOf[GroupA]))
    )
  )
  var age: Int = _
}

class Name2 {
  @BeanProperty
  @Pattern.List(
    Array(
      new Pattern(regexp = "^(カツオ|ワカメ)$", groups = Array(classOf[GroupB])),
      new Pattern(regexp = "^(サザエ|マスオ|タラオ)$", groups = Array(classOf[GroupC]))
    )
  )
  var first: String = _

  @BeanProperty
  @Pattern.List(
    Array(
      new Pattern(regexp = "^磯野$", groups = Array(classOf[GroupB])),
      new Pattern(regexp = "^フグ田$", groups = Array(classOf[GroupC]))
    )
  )
  var last: String = _
}

ネストしたName2クラスには、GroupB、GroupCのGroupが付与されています。で、外側のUser2はDefaultとGroupAを使うようになっています。

そこで、以下のように@ConvertGroupを付与して、Default時にName2はGroupBとしてバリデーションされるように、GroupAの時はGroupCとしてバリデーションされるようにしてみます。

  @BeanProperty
  @Valid
  @ConvertGroup.List(
    Array(
      new ConvertGroup(from = classOf[Default], to = classOf[GroupB]),
      new ConvertGroup(from = classOf[GroupA], to = classOf[GroupC])
    )
  )
  var name: Name2 = _

JAX-RSリソース側。まずはDefaultから。

  @Path("default2")
  @POST
  @Produces(Array(MediaType.APPLICATION_JSON))
  def default2(@Valid user: User2): User2 =
    user

テストがOKになるのは、User2がDefault、Name2がGroupBの時だけです。

    it("Default Group 2, valid") {
      val user = new User2
      user.name = new Name2
      user.name.first = "カツオ"
      user.name.last = "磯野"
      user.age = 12

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/default2")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.OK.getStatusCode)
        val responseEntity = response.readEntity(classOf[User2])
        responseEntity.name.first = "カツオ"
        responseEntity.name.last = "磯野"

        response.close()
      } finally {
        client.close()
      }
    }

Name2側が、GroupBとして扱われていることになります。

では、DefaultをGroupAとして扱うようなメソッドを、JAX-RS側に追加します。

  @Path("convertGroupA2")
  @POST
  @Produces(Array(MediaType.APPLICATION_JSON))
  def convertGroupA2(@Valid @ConvertGroup(from = classOf[Default], to = classOf[GroupA]) user: User2): User2 =
    user

今度は、User2がGroupAとして、Name2がGroupCとしてバリデーションされるようになります。

    it("GroupA 2, valid") {
      val user = new User2
      user.name = new Name2
      user.name.first = "サザエ"
      user.name.last = "フグ田"
      user.age = 24

      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/rest/validation/convertGroupA2")
            .request
            .post(Entity.json(user))

        response.getStatus should be(Response.Status.OK.getStatusCode)
        val responseEntity = response.readEntity(classOf[User2])
        responseEntity.name.first = "サザエ"
        responseEntity.name.last = "フグ田"

        response.close()
      } finally {
        client.close()
      }
    }

動きましたね!

おわりに

今回は、@ConvertGroupで@Valid使用時のGroupの読み替えを試してみました。Groupまわりの話が、またひとつわかった気がします。

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

省略した、バリデーションがNGになるケースも入っています。