この記事は MicroAd Advent Calendar 2020 の14日目の記事です。
この記事では、http4sのQueryParamDecoderMatcherたちの挙動を紹介したり、クエリパラメータ受け取ってそのままクラスへ変換する方法を紹介します。
↓大体公式かソースコードを読みにいけばなんとかなりますが、まとめてみたのでどうぞ!
https://http4s.org/v0.21/dsl/ Handling query parameters
↓QueryParamDecoderMatcherたちがいるところ
org/http4s/dsl/impl/Path.scala
QueryParamDecoderMatcherたちの挙動
QueryParamDecoderMatcherの仲間は以下のようですね!それぞれ挙動を確認していきます。
- QueryParamDecoderMatcher
- OptionalQueryParamDecoderMatcher
- OptionalMultiQueryParamDecoderMatcher
- ValidatingQueryParamDecoderMatcher
- OptionalValidatingQueryParamDecoderMatcher
- おまけ: FlagQueryParamMatcher
紹介する形式としては以下のようになります。
- 自前のプログラム
- 実行結果
ライブラリのコードはご自身で見に行ってみてください!
これより、以下のhttpRoutesを書き換えてやっていきます。
object Server extends IOApp {
def run(args: List[String]): IO[ExitCode] =
BlazeServerBuilder[IO](global)
.bindHttp(8888, "localhost")
.withHttpApp(httpRoutes.orNotFound)
.resource
.use(_ => IO.never)
.as(ExitCode.Success)
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root =>
Ok("HelloWorld")
}
}
QueryParamDecoderMatcher
※正直QueryParamDecoderMatcher#unapplySeqはようわからないので取り扱いません…
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "weather" / "temperature"
:? CountryQueryParamMatcher(country)
+& YearQueryParamMatcher(year) =>
Ok(s"country: $country, year: $year")
}
object CountryQueryParamMatcher extends QueryParamDecoderMatcher[String]("country")
implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
QueryParamDecoder[Int].map(Year.of)
object YearQueryParamMatcher extends QueryParamDecoderMatcher[Year]("year")
実行結果
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan&year=2020'
country: Japan, year: 2020
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan'
Not found
❯ curl -X GET 'localhost:8888/weather/temperature?year=2020'
Not found
❯ curl -X GET 'localhost:8888/weather/temperature?year=2020&country=Japan'
country: Japan, year: 2020
❯ curl -X GET 'localhost:8888/weather/temperature'
Not found
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan&year=2020&country=Napaj'
country: Japan, year: 2020
OptionalQueryParamDecoderMatcher
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "weather" / "temperature"
:? CountryQueryParamMatcher(country)
+& YearQueryParamMatcher(year) =>
Ok(s"country: $country, year: $year")
}
object CountryQueryParamMatcher extends OptionalQueryParamDecoderMatcher[String]("country")
implicit val yearQueryParamDecoder: QueryParamDecoder[Year] =
QueryParamDecoder[Int].map(Year.of)
object YearQueryParamMatcher extends OptionalQueryParamDecoderMatcher[Year]("year")
実行結果
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan&year=2020'
country: Some(Japan), year: Some(2020)
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan'
country: Some(Japan), year: None
❯ curl -X GET 'localhost:8888/weather/temperature'
country: None, year: None
❯ curl -X GET 'localhost:8888/weather/temperature?country=Japan&country=Napaj'
country: Some(Japan), year: None
OptionalMultiQueryParamDecoderMatcher
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
// プリミティブな文字列
case GET -> Root
:? QMatcher(q) =>
Ok(s"q: $q")
// クエリパラメータに渡された文字列が整数ならクラスに変換
case GET -> Root / "foo"
:? FooMatcher(q) =>
Ok(s"foo: $q")
}
object QMatcher extends OptionalMultiQueryParamDecoderMatcher[String]("q")
case class Foo(i: Int)
implicit val fooDecoder: QueryParamDecoder[Foo] =
QueryParamDecoder[Int].map(Foo)
object FooMatcher extends OptionalMultiQueryParamDecoderMatcher[Foo]("foo")
出力結果
❯ curl -X GET 'localhost:8888'
q: Valid(List())
❯ curl -X GET 'localhost:8888?q=q1'
q: Valid(List(q1))
❯ curl -X GET 'localhost:8888?q=q1&q=q2'
q: Valid(List(q1, q2))
❯ curl -X GET 'localhost:8888/foo'
foo: Valid(List())
❯ curl -X GET 'localhost:8888/foo?foo=1'
foo: Valid(List(Foo(1)))
❯ curl -X GET 'localhost:8888/foo?foo=1&foo=2'
foo: Valid(List(Foo(1), Foo(2)))
❯ curl -X GET 'localhost:8888/foo?foo=1&foo=a'
foo: Invalid(NonEmptyList(org.http4s.ParseFailure: Query decoding Int failed: For input string: "a"))
❯ curl -X GET 'localhost:8888/foo?foo=1&foo=a&foo=b'
foo: Invalid(NonEmptyList(org.http4s.ParseFailure: Query decoding Int failed: For input string: "a", org.http4s.ParseFailure: Query decoding Int failed: For input string: "b"))
ValidatingQueryParamDecoderMatcher
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "foo"
:? FooMatcher(q) =>
Ok(s"foo: $q")
}
case class Foo(i: Int)
implicit val fooDecoder: QueryParamDecoder[Foo] =
QueryParamDecoder[Int].map(Foo)
object FooMatcher extends ValidatingQueryParamDecoderMatcher[Foo]("foo")
実行結果
❯ curl -X GET 'localhost:8888/foo'
Not found
❯ curl -X GET 'localhost:8888/foo?foo=1'
foo: Valid(Foo(1))
❯ curl -X GET 'localhost:8888/foo?foo='
foo: Invalid(NonEmptyList(org.http4s.ParseFailure: Query decoding Int failed: For input string: ""))
❯ curl -X GET 'localhost:8888/foo?foo=1&foo=2'
foo: Valid(Foo(1))
OptionalValidatingQueryParamDecoderMatcher
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "foo"
:? FooMatcher(q) =>
Ok(s"foo: $q")
}
case class Foo(i: Int)
implicit val fooDecoder: QueryParamDecoder[Foo] =
QueryParamDecoder[Int].map(Foo)
object FooMatcher extends OptionalValidatingQueryParamDecoderMatcher[Foo]("foo")
実行結果
❯ curl -X GET 'localhost:8888/foo'
foo: None
❯ curl -X GET 'localhost:8888/foo?foo=1'
foo: Some(Valid(Foo(1)))
❯ curl -X GET 'localhost:8888/foo?foo='
foo: Some(Invalid(NonEmptyList(org.http4s.ParseFailure: Query decoding Int failed: For input string: "")))
❯ curl -X GET 'localhost:8888/foo?foo=1&foo=2'
foo: Some(Valid(Foo(1)))
おまけ: FlagQueryParamMatcher
プログラム
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "flag"
:? MyFlagQueryParamMatcher(myFlag) =>
Ok(s"myFlag: $myFlag")
}
object MyFlagQueryParamMatcher extends FlagQueryParamMatcher("myFlag")
実行結果
❯ curl -X GET 'localhost:8888/flag'
myFlag: false
❯ curl -X GET 'localhost:8888/flag?myFlag='
myFlag: true
❯ curl -X GET 'localhost:8888/flag?myFlag=Hello'
myFlag: true
❯ curl -X GET 'localhost:8888/flag?myFlag=false'
myFlag: true
❯ curl -X GET 'localhost:8888/flag?myFlag=0'
myFlag: true
クエリパラメータ受け取って、そのままクラスへ変換する方法
クエリパラメータ受け取ったら、そのままクラスに変換したい時があると思います。
そこで今回はその方法をいくつか紹介したいと思います。
- プリミティブを受け取ってそのままクラスにする
- QueryParamDecoder.fromUnsafeCastを使う
- 自分でQueryParamDecoderを実装しちゃう
- ゴリ押しで
unapply(params: Map[String, collection.Seq[String]]): Option[クラス名]
するようなobjectを作る
以下のプログラムを使っていきます。
val httpRoutes: HttpRoutes[IO] = HttpRoutes
.of[IO] {
case GET -> Root / "foo"
:? FooMatcher(q) =>
Ok(s"foo: $q")
}
case class Foo(i: Int)
プリミティブを受け取ってそのままクラスにする
implicit val fooDecoder: QueryParamDecoder[Foo] =
QueryParamDecoder[Int].map(Foo)
object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
QueryParamDecoder.fromUnsafeCastを使う
fromUnsafeCastを使うやり方です。こいつの場合、Fooのインスタンスが生成できなかったときはNotFoundが返ってくるようです。
implicit val fooDecoder: QueryParamDecoder[Foo] =
QueryParamDecoder.fromUnsafeCast[Foo](x => Foo(x.value.toInt))("Foo")
object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
自分でQueryParamDecoderを実装しちゃう
implicit val fooDecoder: QueryParamDecoder[Foo] =
new QueryParamDecoder[Foo] {
def decode(value: QueryParameterValue): ValidatedNel[ParseFailure, Foo] =
Validated
.catchNonFatal(Foo(value.value.toInt))
.leftMap(t => ParseFailure(s"Query decoding Foo failed", t.getMessage))
.toValidatedNel
}
object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
ちなみに以上の書き方はSAM変換できますね…!QueryParamDecoderの抽象メソッドが一つだけなので!
implicit val fooDecoder: QueryParamDecoder[Foo] =
(value: QueryParameterValue) => Validated
.catchNonFatal(Foo(value.value.toInt))
.leftMap(t => ParseFailure(s"Query decoding Foo failed", t.getMessage))
.toValidatedNel
object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
ゴリ押しでunapply(params: Map[String, collection.Seq[String]]): Option[クラス名]
するようなobjectを作る
もはや自分でQueryParamDecoderMatcherを作ってしまう。
ちなみに以下の例の場合、クエリパラメータが指定されてなくてもインスタンスを生成してくれます。痒いところに手が届きますね。
object FooMatcher {
val name = "foo"
def unapply(params: Map[String, collection.Seq[String]]): Option[Foo] =
params
.get(name)
.flatMap(_.headOption)
.flatMap(s => QueryParamDecoder[String].decode(QueryParameterValue(s)).toOption) match {
case None => Foo(0).some
case Some(v) =>
Either.catchNonFatal(Foo(v.toInt)) match {
case Right(foo) => foo.some
case Left(_) => Foo(0).some
}
}
}
以上になります。ありがとうございました。