Scala
AdventCalendar
Akka
circe
ScalaDay 12

circeをつかったバリデーションの実装

はじめに

この記事はScala Advent Calendar 2018の12月12日の記事です。

アルプ株式会社でエンジニアをしていますshowmantです。

概要

今回取り組んだ内容の課題感としては本Advent Calendarの2日目にuryyyyyyyさんが書いてくださったことに似ています。

Akka HTTPのバリデーションライブラリを作った

現在SaaSのプロダクトを開発中なのですが、いろいろな事情からPlay Frameworkを使わず、Akka-http + circe + バリデーションライブラリーで組むことにしました。

そこで「formのfieldバリデーション方法」が課題になりました。

やりたかったこと

  • web formから送信されたリクエストをAkka-httpで受け取り、受け取ったJSONをcirceをつかってシリアライズし、フィールドのバリデーションしたい
  • エラーが有った場合に、独自で定義したエラーオブジェクトに包んでレスポンスとして返したい

環境

  • Scala 2.12系
  • Akka-HTTP 10.1.5系
  • circe 0.9.3
  • cats 1.4.0

circeに対応したバリデーションライブラリはいくつかありました。

下記にあげさせていだきますm(_ _)m

https://github.com/tabmo/circe-validation

https://github.com/taig/circe-validation

今回は値のバリデーションロジックは極力自前で実装したくなかったため、tabmoさんのものを選択しました。

流れの説明

Akka-HTTPの実装手順に関しては今回は割愛しますが、イメージとしては下記のような流れです。

    case class Person(
    firstName: String,
     lastName: String,
     age: Int,
     email: String)

    //独自に定義したErrorクラス。複数のfieldにエラーがあった場合はエラーを積んで返したい。
    case class FormError(errors: List[io.circe.Error]) extends Throwable

    class PersonController() {
      implicit val actorMaterializer: ActorMaterializer = ??? //略
      implicit val ec: ExecutionContext = ??? //略

      def routes: Route =
        path("person") {
          pathEndOrSingleSlash {
            post {
              extract(ctx => ctx.request) { request =>
                onComplete {
                  for {
                    result <- extractRequest(request)
                  } yield result
                } {
                  case Success(s) => s match {
                    case Right(person) => complete(person.firstName)
                    case Left(error) => failWith(error)
                  }
                  case Failure(exception) => failWith(exception)
                }
              }

            }
          }
        }
      def extractRequest(request: HttpRequest): Future[Either[FormError, Person]] =
        request.entity.dataBytes
          .runFold(ByteString.empty)(_ ++ _)
          .map(_.utf8String)
          .map(jsonToPerson)

      def jsonToPerson(stringJson: String): Either[FormError, Person] = {
        implicit def decoder: io.circe.Decoder[Person] = ???

        io.circe.parser.decodeAccumulating(stringJson) match {
          case Valid(value)    => Right(value)
          case Invalid(errors) => Left(FormError(errors.toList))

        }
      }

    }

ここで ??? となっているDecoderをどう実装するか、が課題になります。

トライ 1

ひとまず、ライブラリにあるUsageを試してみました。

    val decodePerson: Decoder[Person] = Decoder.instance[Person] { (c: Hcursor) =>  
      for {
        name        <- c.downField("name").read(StringRules.maxLength(32))
        lastName    <- c.downField("lastName").as[String]
        age         <- c.downField("age").read(IntRules.positive())
        email       <- c.downField("email").read(StringRules.email)
      } yield Person(name, lastName, age, email)
    }

記述もシンプルでかなり直感的に実装できます。

これでも十分なのですが、今回はどうしても、「全フィールドの値をチェックして配列で返したい」という要件を満たしたく、この実装だと、仮に name の長さが32字より大きくなってバリデーションに失敗すると、そこで処理が終了してしまい、後続のその他のフィールドに関しては探索してもらえないです。

故にdecodeAccumulatingを呼び出しても、エラーを蓄積してもらえません。

ちなみにread(ルール) の返り値は Either[DecodingFailure, A] となっていて、Decoder.instanceメソッドは以下のようになっています。

    type Result[A] = Either[DecodingFailure, A]
    final def instance[A](f: HCursor => Result[A]): Decoder[A] = new Decoder[A] {
        final def apply(c: HCursor): Result[A] = f(c)
      }

このResultがエラーを積んでいく仕組みになっていればいいんだな。と考えました。

トライ2

そういうのありそうだなとライブラリを探し回っていると、circeのなかにDecoderクラスだけでなく、AccumulationDecoderクラスが存在していることを発見しました。

そちらのinstance関数は以下のとおりです。

    final type Result[A] = ValidatedNel[DecodingFailure, A]
    //type ValidatedNel[+E, +A] = Validated[NonEmptyList[E], A]

    final def instance[A](f: HCursor => Result[A]): AccumulatingDecoder[A] = new AccumulatingDecoder[A] {
        final def apply(c: HCursor): Result[A] = f(c)
      }

シグニチャを見る限り、これで行けそうだったので、簡単に下記のような実装イメージをまず考えました。

    implicit def toValidatedNel[A](either: Either[DecodingFailure, A]): ValidatedNel[DecodingFailure, A] = ???
    implicit def decoder: AccumulatingDecoder[Person] = AccumulatingDecoder.instance[Person] { c: HCursor =>
        for {
          name        <- c.downField("name").read(StringRules.maxLength(32))
          lastName    <- c.downField("lastName").as[String]
          age         <- c.downField("age").read(IntRules.positive())
          email       <- c.downField("email").read(StringRules.email)
        } yield Person(name, lastName, age, email)
      }

なんかいけそうだなと思いました。が、あることに気づきました。すごく単純ですが、AccumulatingDecoderDecoderクラスでもないし、Decoderクラスを継承しているわけでもないのです。

つまり、ほしかったDecoder[A]をつくることができないのですorz

強烈な思わせぶりですね。。。。w(もしかしたらこの方式でうまくやる方法があるのかもしれませんが、今回は見つけられず。。ご存知の方いればご教示いただけると幸いですm(_ _)m)

最終的にできたこと

結果として、下記のコードでバリデーションをかけつつ、複数のfieldエラーを返却することができました。

    def decoder: Decoder[Person] =
        (firstNameDecoder, lastNameDecoder, ageDecoder, emailDecoder).mapN(Person.apply)

      private[this] def firstNameDecoder: Decoder[String] =
        Decoder.instance(_.downField("user_id").read(StringRules.isNotEmpty()))
      private[this] def lastNameDecoder: Decoder[String] =
        Decoder.instance(_.downField("lastName").read(StringRules.maxLength(32)))
      private[this] def ageDecoder: Decoder[Int]      = Decoder.instance(_.downField("age").read(IntRules.positive()))
      private[this] def emailDecoder: Decoder[String] = Decoder.instance(_.downField("age").read(StringRules.email))

今後やりたいこと

もうすこしシンプルに書く方法みつけたい

上記はシンプルなクラスなので、そこまで煩雑ではありませんが、JSONが入れ子になっていたりすると記述方法がやや冗長になります。

case class Person(name: Name, age: Int)
  case class Name(firstName: String, lastName: String)
  def create: Decoder[Person] =
    (nameDecoder, ageDecoder).mapN(Person.apply)

  private[this] def nameDecoder: Decoder[Name] = Decoder.instance(_.get[Name]("name"))
  private[this] def ageDecoder: Decoder[Int]   = Decoder.instance(_.downField("age").read(IntRules.positive()))

  private[this] implicit def name: Decoder[Name] = (firstNameDecoder, lastNameDecoder).mapN(Name.apply)

  private[this] def firstNameDecoder: Decoder[String] =
    Decoder.instance(_.downField("first_name").read(StringRules.isNotEmpty()))
  private[this] def lastNameDecoder: Decoder[String] =
    Decoder.instance(_.downField("last_name").read(StringRules.notBlank()))

1フィールドに対して複数のバリデーションをかけたときに、両方を評価できるようにしたい

この方法だと1行に2つバリデーションを定義し、両方失敗したケースに最初のエラーしか返してくれません。

まとめ

要はAkka-HTTP + circeで上記のような要件を満たしたいだけだったので、もし他によい方法があれば、教えていただけますとありがたいです。

書き終わってちょっと調べてみた感じ、io.circe.Parser にちょっと手を加えたらAccumulatingDecoderクラスのDecoderを実装しつつ、Writerでエラー蓄積ができるのではないかと考えています。

この記事には間に合いませんでしたが、追って投稿します。