はじめに
この記事はScala Advent Calendar 2018の12月12日の記事です。
アルプ株式会社でエンジニアをしていますshowmantです。
概要
今回取り組んだ内容の課題感としては本Advent Calendarの2日目にuryyyyyyyさんが書いてくださったことに似ています。
現在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)
}
なんかいけそうだなと思いました。が、あることに気づきました。すごく単純ですが、AccumulatingDecoder
はDecoder
クラスでもないし、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
でエラー蓄積ができるのではないかと考えています。
この記事には間に合いませんでしたが、追って投稿します。