LoginSignup
6
0

More than 3 years have passed since last update.

http4sのQueryParamDecoderMatcherたちの挙動を確認してみた+クエリパラメータ受け取ってそのままクラスへ変換する方法

Last updated at Posted at 2020-12-13

この記事は 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の仲間は以下のようですね!それぞれ挙動を確認していきます。
1. QueryParamDecoderMatcher
2. OptionalQueryParamDecoderMatcher
3. OptionalMultiQueryParamDecoderMatcher
4. ValidatingQueryParamDecoderMatcher
5. OptionalValidatingQueryParamDecoderMatcher
6. おまけ: 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

クエリパラメータ受け取って、そのままクラスへ変換する方法

クエリパラメータ受け取ったら、そのままクラスに変換したい時があると思います。
そこで今回はその方法をいくつか紹介したいと思います。

  1. プリミティブを受け取ってそのままクラスにする
  2. QueryParamDecoder.fromUnsafeCastを使う
  3. 自分でQueryParamDecoderを実装しちゃう
  4. ゴリ押しで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
          }
      }
  }

以上になります。ありがとうございました。

6
0
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
6
0