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しています。
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))
}
}
}
一旦は空のレスポンスを返すようにしています。
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に失敗して無事テストがコケます。
テストを通すためには、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))
)
}
}
無事テストが通りました。
case classを利用してレスポンスを返す。
実際にはcase class等を利用してモデルをレスポンスのJsonオブジェクトに変換するケースが多いでしょう。仮に以下のようなレスポンスを期待します。
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メソッドをコピーします。
def user: Action[AnyContent] = Action.async { implicit request =>
Future(
Ok(Json.obj("hello" -> "world".asJson))
)
}
差分がわかりやすい形で表示されるので嬉しいですね。
大抵の型であれば、以下の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)
)
}
}
リクエストとしてJsonを受け取る
コントローラのテストでリクエストとしてJsonを渡す場合は準備がそれなりに必要です。今まで使用してきたcontentAsCirceJson
と共にHelperとしてtraitに切り出しています。特に重要なのがテストのcallメソッドがJsonを内部でどう受け取り解釈するか、Writeable[Json]
を必要とします。このロジックはplay-circeが実際に行っている部分なので、ライブラリの中身から必要な部分だけを切り出しています。難しい処理では無いのでサッと読んで理解しておきましょう。また、FakeRequestにcirceが扱いやすいようにwithCirceJsonBody
のメソッドをimplicitで生やします。ここまで準備が整ったら、あとは普段のコントローラのテストと変わりありません。
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(""))
+ }
無事失敗します。
def addUser(): Action[Json] = Action(circe.json(1024)).async { implicit request =>
Future(Created)
}
さすがにこれでは味気が無いのでテストケースを追加します。
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)
}
}
}
バリデーションを含んだ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")
}
)
}
おまけに空白も許可しないようにしてみましょう。
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は更に直感的に使え強力です。導入は難しくないので検討してみてはいかがでしょうか?