10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

circeは、CatsやShapelessを使っている強力なJSONライブラリです。この記事ではPlayでcirceを使うための方法を紹介したいと思います。Playでより実践的な形で使うためにcirceを実装だけではなくテストでも利用する方法を紹介します。

全体のサンプルは以下のリポジトリで見ることができます。

circeの導入

ライブラリの追加

今回、以下のライブラリを使用します。

libraryDependencies ++= Seq(
   "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.1" % Test,
   "io.circe" %% "circe-core" % "0.10.0",
   "io.circe" %% "circe-generic" % "0.10.0",
   "io.circe" %% "circe-parser" % "0.10.0",
   "io.tabmo" %% "circe-validation-core" % "0.0.6",
   "io.tabmo" %% "circe-validation-extra-rules" % "0.0.6",
   "com.dripower" %% "play-circe" % "2611.0"
 )

単純なレスポンスを返す

まずは単純なJsonオブジェクトを返す例を紹介します。ポイントはControllerが返す値をparseするcontentAsJsonのcirce版を用意してあげることです。テストなので乱暴にright.getしています。

test/controllers/HomeControllerSpec.scala
package controllers

import org.scalatest.FunSpec
import org.scalatestplus.play.guice._
import play.api.mvc.{ControllerComponents, Result}
import play.api.test._
import io.circe.Json
import io.circe.parser._
import io.circe.syntax._
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future}

class HomeControllerSpec extends FunSpec with GuiceOneAppPerTest {
   private[this] def createController(): HomeController = {
     implicit val ec: ExecutionContextExecutor = ExecutionContext.global
     val cc = app.injector.instanceOf[ControllerComponents]
     new HomeController(cc)(ec)
   }

   import play.api.test.Helpers._

   private[this] def contentAsCirceJson(of: Future[Result]): Json =
     parse(contentAsString(of)).right.get

 
   describe("#hello") {
     it("hello world JSONを返す") {
       val controller = createController()

       val result = controller.hello
         .apply(FakeRequest())
       val responseBody = contentAsCirceJson(result)

       assert(status(result) == 200)
       assert(responseBody == Json.obj("hello" -> "world".asJson))
     }
  }
}

一旦は空のレスポンスを返すようにしています。

app/controllers/HomeController.scala
package controllers

import javax.inject._
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class HomeController @Inject()(
   cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  def hello: Action[AnyContent] = Action.async { implicit request =>
    Future(Ok(""))
  }
}

Jsonを返していないのでparseに失敗して無事テストがコケます。

スクリーンショット 2019-05-02 12.16.13.png

テストを通すためには、Circe traitをミックスインしJsonを返すように変更を加えます。

package controllers

import play.api.mvc._
import javax.inject._
import scala.concurrent.{ExecutionContext, Future}
+ import play.api.libs.circe.Circe
+ import io.circe.Json
+ import io.circe.syntax._


@Singleton
class HomeController @Inject()(
  cc: ControllerComponents
+ )(implicit ec: ExecutionContext) extends AbstractController(cc) with Circe {

  def hello: Action[AnyContent] = Action.async { implicit request =>
    Future(
+      Ok(Json.obj("hello" -> "world".asJson))
    )
  }
}

無事テストが通りました。

スクリーンショット 2019-05-02 12.21.17.png

case classを利用してレスポンスを返す。

実際にはcase class等を利用してモデルをレスポンスのJsonオブジェクトに変換するケースが多いでしょう。仮に以下のようなレスポンスを期待します。

app/controllers/HomeController.scala
describe("#user") {
  it("User JSONを返す") {
    val controller = createController()

    val result = controller.user
      .apply(FakeRequest())
    val responseBody = contentAsCirceJson(result)

    assert(status(result) == 200)
    assert(responseBody ==
      Json.obj(
        "id" -> 1.asJson,
        "name" -> "John".asJson
      )
    )
  }
}

先程のhelloメソッドをコピーします。

app/controllers/HomeController.scala
def user: Action[AnyContent] = Action.async { implicit request =>
  Future(
    Ok(Json.obj("hello" -> "world".asJson))
  )
}

差分がわかりやすい形で表示されるので嬉しいですね。

スクリーンショット 2019-05-02 12.26.18.png

大抵の型であれば、以下のimport文を書けば、そのままJsonを書き出すことができます。
import io.circe.generic.auto._
CatsやShapelessの威力が発揮される部分です。

package controllers

import javax.inject._
import scala.concurrent.{ExecutionContext, Future}
import play.api.libs.circe.Circe
import play.api.mvc._
import io.circe.Json
import io.circe.syntax._
+ import io.circe.generic.auto._

+ case class User(id: Int, name: String)

@Singleton
class HomeController @Inject()(
  cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) with Circe {

  ...

  def user: Action[AnyContent] = Action.async { implicit request =>
    Future(
+      Ok(User(1, "John").asJson)
    )
  }
}
スクリーンショット 2019-05-02 12.31.45.png

リクエストとしてJsonを受け取る

コントローラのテストでリクエストとしてJsonを渡す場合は準備がそれなりに必要です。今まで使用してきたcontentAsCirceJsonと共にHelperとしてtraitに切り出しています。特に重要なのがテストのcallメソッドがJsonを内部でどう受け取り解釈するか、Writeable[Json]を必要とします。このロジックはplay-circeが実際に行っている部分なので、ライブラリの中身から必要な部分だけを切り出しています。難しい処理では無いのでサッと読んで理解しておきましょう。また、FakeRequestにcirceが扱いやすいようにwithCirceJsonBodyのメソッドをimplicitで生やします。ここまで準備が整ったら、あとは普段のコントローラのテストと変わりありません。

test/controllers/HomeControllerSpec.scala
package controllers

import akka.stream.Materializer
import org.scalatest.FunSpec
import org.scalatestplus.play.guice._
import play.api.mvc.{ControllerComponents, Result}
import play.api.http._
import play.api.mvc.{Codec, Result}
import play.api.test.FakeRequest
import io.circe._
import io.circe.parser._
import io.circe.syntax._
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future}

trait CirceTestHelper {

  import play.api.test.Helpers._

  def contentAsCirceJson(of: Future[Result]): Json =
    parse(contentAsString(of)).right.get

  implicit class RichFakeRequest[T](fakeRequest: FakeRequest[T]) {
    def withCirceJsonBody(json: Json): FakeRequest[Json] = fakeRequest.withBody[Json](json)
  }

  /**
    * from play.api.libs.circe.Circe
    */
  private val defaultPrinter = Printer.noSpaces

  implicit val contentTypeOf_Json: ContentTypeOf[Json] = {
    ContentTypeOf(Some(ContentTypes.JSON))
  }

  implicit def writableOf_Json(implicit codec: Codec, printer: Printer = defaultPrinter): Writeable[Json] = {
    Writeable(a => codec.encode(a.pretty(printer)))
  }
}

class HomeControllerSpec extends FunSpec with GuiceOneAppPerTest with CirceTestHelper {

  import play.api.test.Helpers._

  private[this] def createController(): HomeController = {
    implicit val ec: ExecutionContextExecutor = ExecutionContext.global
    val cc = app.injector.instanceOf[ControllerComponents]
    new HomeController(cc)(ec)
  }

  ...

  describe("#addUser") {
    it("CREATED Statusを返す") {
      implicit lazy val materializer: Materializer = app.materializer
      val controller = createController()

      val request = FakeRequest("POST", "/user")
        .withCirceJsonBody(
          Json.obj("name" -> "Mike".asJson)
        )
      val result = call(controller.addUser(), request)

      assert(status(result) == CREATED)
    }
  }
}

実装上の注意点として、circe.jsonの引数にJsonのサイズを明示的に指定しなければテスト時に値が無いと怒られてしまうので指定をしておきましょう。(テストを書かない場合はデフォルト値がconfから呼び出されます。)

+ case class UserCommand(name: String)

+   def addUser(): Action[Json] = Action(circe.json(1024)).async { implicit request => 
+     Future(Ok(""))
+ } 

無事失敗します。

スクリーンショット 2019-05-02 12.56.29.png
def addUser(): Action[Json] = Action(circe.json(1024)).async { implicit request =>
  Future(Created)
}
スクリーンショット 2019-05-02 12.58.11.png

さすがにこれでは味気が無いのでテストケースを追加します。

  describe("#addUser") {
    describe("nameを渡すと") {
      it("CREATED Statusを返す") {
        implicit lazy val materializer: Materializer = app.materializer
        val controller = createController()

        val request = FakeRequest("POST", "/user")
          .withCirceJsonBody(
            Json.obj("name" -> "Mike".asJson)
          )
        val result = call(controller.addUser(), request)

        assert(status(result) == CREATED)
      }
    }

    describe("100文字より大きいnameを渡すと") {
      it("BAD_REQUEST Statusを返す") {
        implicit lazy val materializer: Materializer = app.materializer
        val controller = createController()

        val request = FakeRequest("POST", "/user")
          .withCirceJsonBody(
            Json.obj("name" -> ("a" * 101).asJson)
          )
        val result = call(controller.addUser(), request)

        assert(status(result) == BAD_REQUEST)
      }
    }
  }
スクリーンショット 2019-05-02 13.00.40.png

バリデーションを含んだDcoderを定義するのは非常に簡単です。自身でバリデーションルールを定義することも簡単に行なえます。

import io.circe.{Decoder, Json}
import io.tabmo.circe.extra.rules.StringRules

object UserCommand {
   import io.tabmo.json.rules._

    implicit val decoder: Decoder[UserCommand] = Decoder.instance[UserCommand] { c =>
     for {
       name <- c.downField("name").read(StringRules.maxLength(100))
     } yield UserCommand(name)
   }
 }

 def addUser(): Action[Json] = Action(circe.json(1024)).async { implicit request =>
    Future(
      UserCommand.decoder.decodeJson(request.body) match {
        case Right(_) => Created
        case Left(_) => BadRequest("validation error")
      }
    )
  }
スクリーンショット 2019-05-02 13.07.56.png

おまけに空白も許可しないようにしてみましょう。

describe("空白のnameを渡すと") {
  it("BAD_REQUEST Statusを返す") {
    implicit lazy val materializer: Materializer = app.materializer
    val controller = createController()

    val request = FakeRequest("POST", "/user")
      .withCirceJsonBody(
        Json.obj("name" -> " ".asJson)
      )
    val result = call(controller.addUser(), request)

    assert(status(result) == BAD_REQUEST)
  }
}
+ name <- c.downField("name").read(StringRules.maxLength(100) |+| StringRules.notBlank())

まとめ

circeライブラリ導入の手法を紹介しました。ハマりどころさえ分かっていれば、小さなHelper traitを一つ作るだけで簡単にテストと実装でcirceを使えるようになります。Play Jsonも強力ですが、circeは更に直感的に使え強力です。導入は難しくないので検討してみてはいかがでしょうか?

10
5
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
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?