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

この記事は MicroAd Advent Calendar 2020 の1４日目の記事です。

この記事では、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を書き換えてやっていきます。

```scala
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はようわからないので取り扱いません…

プログラム

```scala
  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")
```    

実行結果

```bash
❯ 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

プログラム

```scala
  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")    
```

実行結果

```bash
❯ 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

プログラム

```scala
  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")    
```

出力結果

```bash
❯ 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

プログラム

```scala
  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")    
```

実行結果

```bash
❯ 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

プログラム

```scala
  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")    
```

実行結果

```bash
❯ 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

プログラム

```scala
  val httpRoutes: HttpRoutes[IO] = HttpRoutes
    .of[IO] {
      case GET -> Root / "flag"
          :? MyFlagQueryParamMatcher(myFlag) =>
        Ok(s"myFlag: $myFlag")
    }
  object MyFlagQueryParamMatcher extends FlagQueryParamMatcher("myFlag")
```

実行結果

```bash
❯ 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を作る

以下のプログラムを使っていきます。

```scala
  val httpRoutes: HttpRoutes[IO] = HttpRoutes
    .of[IO] {
      case GET -> Root / "foo"
          :? FooMatcher(q) =>
        Ok(s"foo: $q")
    }
  case class Foo(i: Int)
```

#### プリミティブを受け取ってそのままクラスにする

```scala
  implicit val fooDecoder: QueryParamDecoder[Foo] =
    QueryParamDecoder[Int].map(Foo)
  object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
```

#### QueryParamDecoder.fromUnsafeCastを使う

fromUnsafeCastを使うやり方です。こいつの場合、Fooのインスタンスが生成できなかったときはNotFoundが返ってくるようです。

```scala
  implicit val fooDecoder: QueryParamDecoder[Foo] =
    QueryParamDecoder.fromUnsafeCast[Foo](x => Foo(x.value.toInt))("Foo")
  object FooMatcher extends OptionalQueryParamDecoderMatcher[Foo]("foo")
```

#### 自分でQueryParamDecoderを実装しちゃう

```scala
  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の抽象メソッドが一つだけなので！

```scala
  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を作ってしまう。
ちなみに以下の例の場合、クエリパラメータが指定されてなくてもインスタンスを生成してくれます。痒いところに手が届きますね。

```scala
  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
          }
      }
  }
```

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