yoskhdia’s diary

DDDとかプログラミングとかアーキテクチャとか雑多に

Scalaで良い感じにURI(URL)を操作する

TL;DR

sttpのcoreライブラリだけ使用しました。
しかし、デフォルトでデコードも行われるため、これを回避する方法を考えました。
将来的に使えなくなる可能性はあります(!)

やりたかったこと

クエリパラメータを足したり引いたり、というよくある(?)ユースケースです。

Java標準ライブラリのURIが使いにくい

java.net.URI クラスは、URIを表すだけで加工する用途にはあまり向いていません。 クエリパラメータを足したり引いたりという操作は getQuery() というStringを返すメソッドをもとに頑張るしかありません。 パラメータを足す程度であれば、例えば次のように書けます。*1

scala> val uri = new URI("https://localhost?a=b")
uri: java.net.URI = https://localhost?a=b

scala> val newQuery = if (uri.getQuery == null) "foo=bar" else uri.getQuery + "&foo=bar"
newQuery: String = a=b&foo=bar

scala> uri.resolve("?" + newQuery)
res1: java.net.URI = https://localhost?a=b&foo=bar

ただし、このコードにはいくつかの落とし穴があります*2。 例えば https://localhost? というURIの場合 getQuery はnullではなく "" 空文字を返すため、上のようなコードだと https://localhost?&foo=bar となんだか残念な感じになります*3。 ここに、同じkeyのものを更新したい、とか、特定のパラメータを削除したい、という要件が追加されると、更に難易度が高くなります。

なにより、Stringを直接ゴニョゴニョすることはバグの温床となるため、あまり褒められたものではありません。

URIBuilderは無いのか?

java標準ライブラリのなかでは見つけることができませんでした*4javax.ws.rs.core.UriBuilder というものは見つかりますが、JAX-RSを使わない場合は過剰な依存になってしまいます。

デファクトはコレだというものもなく、色々なライブラリが自前で車輪の再発明をし続けているのが現状のように思います*5

sttpとは

SoftwareMill社が公開しているScalaのHTTPクライアントライブラリです。

github.com

バックエンドにOkHTTPやakka-httpなどを好みに応じて選択できるファサードのようなものです。 core部分とバックエンド部分はライブラリが分かれており、coreは何も依存ライブラリが無い小さなモジュールです。

// sbt
libraryDependencies += "com.softwaremill.sttp" %% "core" % "1.1.6"

とするだけで使用できます。

オレオレUriBuilder

前述のようにデファクトライブラリというものがなく、流行り廃りにさらされることは避けたいところです。 そのため、sttpのUriオブジェクトを直接使うのではなく、これに委譲するUriBuilderを用意します。 用途が限られているので公開APIはあまり変化することがありません。sttpから乗り換えたくなったとしてもUriBuilderのみを修正するだけで多くの場合十分でしょう。

import com.softwaremill.sttp.{ UriContext, Uri }

final class UriBuilder private (private val underlying: Uri) {

  def addQuery(parameters: (String, String)*): UriBuilder = {
    UriBuilder(underlying.params(parameters: _*))
  }

  def removeQuery(keys: String*): UriBuilder = {
    val newQueryFragments = underlying.queryFragments.filterNot {
      case QueryFragment.KeyValue(key, _, _, _) => keys.contains(key)
      case _                                    => false
    }
    UriBuilder(underlying.copy(queryFragments = newQueryFragments))
  }

  def updateQuery(parameters: (String, String)*): UriBuilder = {
    removeQuery(parameters.map(_._1): _*).addQuery(parameters: _*)
  }

  // 他には例えばwithSchemaやwithHostなど...

  def build: String = underlying.toString()

  def buildJavaURI: java.net.URI = underlying.toJavaUri
}

object UriBuilder {

  private def apply(uri: Uri): UriBuilder = {
    new UriBuilder(uri)
  }

  def apply(uriString: String): UriBuilder = {
    // sttpではstring interpolationを使ったパースしかない
    apply(uri"$uriString")
  }
}

クエリパラメータだけ欲しい、それもパーセントエンコーディングされたまま

2022-07-15 v1.5.0でTokenizerがprivate化されたため使えないハックになりました。 Release v1.5.0 · softwaremill/sttp-model · GitHub

さて、これでURIを操作することができるようになりましたが、その結果はStringかURIで取得しています。 クエリパラメータ部分だけが欲しいという要件もあるでしょう。

しかし、sttpを使ってURIをパースすると、queryFragmentsメソッドで取得できるものは、自動的にパーセントエンコード形式からデコードされます*6。 そのため、クエリパラメータをパーセントエンコーディングされたまま取得したいケースでは、少し面倒です。

かといって、Stringを自前でゴニョゴニョすることは褒められたものではありません。 そこで、sttpが公開しているTokenizerを利用します。 例えば特定のkeyに一致するクエリパラメータの値を取得したい場合は次のような感じです。

import com.softwaremill.sttp.UriInterpolator._

def resolvePercentEncodedQueryValue(uriString: String, key: String): Option[String] = {
  def tokenizeQuery(queryString: String): Vector[Token] = {
    Tokenizer.Query.tokenize(queryString)._2
  }
  @tailrec
  def loop(tokens: Vector[Token]): Option[String] = {
    tokens match {
      case StringToken(k) +: EqInQuery +: StringToken(value) +: tail =>
        if (k == key) Some(value) else loop(tail)
      case _ +: tail =>
        loop(tail)
      case _ =>
        None
    }
  }

  val queryStart = uriString.indexOf('?')
  if (queryStart >= 0 && queryStart + 1 <= uriString.length) {
    val queryString = uriString.substring(queryStart + 1)
    val tokens = tokenizeQuery(queryString)
    loop(tokens)
  } else {
    None
  }
}

sttpのTokenizerはURIのパーツごとに定義されており、sttpのなかではURI文字列を頭から順番に分解していくことでパースしています。 そのため、Query部分以降に切り取り、Tokenizer.Query.tokenizeを使うことでkeyや=valueトークンを得ることができます。 あとは用途に応じて検索をかけるだけです。

sttpが自動的にデコードを行うのは、このトークンに分解した後のステップのため、この時点ではパーセントエンコーディングされたままの値を取得することができます。

注意点

ここまでで分かる通り、sttpにかなり依存した解法です。 そのため、もしsttpの公開APIに変更があると、これに追従するコストが発生します。 さらに言えば、メンテナンスされなくなればもっと面倒です*7

唯一の備えはユニットテストをたくさん書くことです。

実はこの解法にたどり着くまでにakka-httpのUriで書き、scala-uriで書き、と2回書き直しています。 ですが、前述のように委譲するかたちで閉じているので、利用側での変更は一切ありませんでした*8。 用途をよく考え、十分なテストを用意しましょう。

余談

?foo のようなクエリパラメータもあります。 今回、色々なライブラリを比較していましたが、これをkeyとして扱う派とvalueとして扱う派がどうやらいるようです*9

Java標準ライブラリにUriBuilderが欲しいですね…

*1:登場するコードはJava 8 + Scala 2.12で確認しています。

*2:ので、真似しないでください

*3:このコード例には他にも残念ポイントがありますがここでは割愛します

*4:ご存知の方がいらっしゃいましたら教えてください…

*5:URIの構築、というだけではライブラリをつくるモチベーションにならない??

*6:decodeを行う部分はprivateなのでハックも面倒です。なにより、エンコード、デコードを自動的に行うことでケアレスミスを減らそうとしている設計意図が感じられます。

*7:これは他のライブラリを使うとしても大なり小なり可能性がありますが

*8:そのためにfinalやprivateを使っています

*9:sttpはvalue派。私はflagと呼ぶ派