Scala
Slack

SlackのWeb APIでURLを含むメッセージがポストできなかった話

※ この情報は2017年7月末のものです。

SlackのWeb API

便利なチャットツールであるSlack。
SlackにはWeb APIがあって、トークンを発行しておく事でHTTPリクエストを用い簡単にメッセージをポストしたり、チャンネルの情報を取得したりできます。
メッセージを送りつけるだけならIncoming webhooksに比べて気軽に使えるので、通知用Bot等に利用されている方も多いと思います。

そんなSlackのWeb APIでメッセージをポストしようとして、エラーが返ってきた時の話をします。

抄録

Slack Web APIのchat.postMessageメソッドのクエリストリングで、 ? を含む文字列を パーセントエンコードせずに 送りつけようとすると 404 File Not Found というエラーになった。
? をパーセントエンコードしたら問題なかった。

(12/08/17 追記)
application/x-www-form-urlencoded の仕様上 ? はパーセントエンコードする必要があるので、パーセントエンコードせずに使用しているのが悪かった。

状況

Scala製のバッチプログラムからSlackのWeb APIを叩くコードを書いていました。
HTTPクライアントにはAkka HTTPを利用していました。

送りつけようとしていたメッセージは、大体以下のようなものです。

○○が発生しました。
ログの場所: https://example.com/hoge/fuga/piyo.log?foo=bar

このメッセージを、Web APIのchat.postMessageメソッドを使ってSlackに投稿しようとしたところ、以下のようなエラーが返ってきました。

404 File Not Found

これは何故だろう? ……という事で調べてみました。

Tester

SlackのWeb APIのサイトには、親切な事にTesterが設置されています。
Web APIを叩くためのURLを生成してくれるので、それをcurlで叩いて試します。

http://example.com?hoge=fuga

というメッセージを、

$ curl "https://slack.com/api/chat.postMessage?token=___&channel=___&text=http%3A%2F%2Fexample.com%3Fhoge%3Dfuga"

として送ってみます。
このSlack通知処理、今までは成功していたのですが、 URLを含むメッセージの場合のみ 失敗していたので、おそらくURLに問題があるのだろうなーと思ってURLだけをメッセージに含めました。

ところが、この処理は成功します。

Akka HTTP

上のURLはSlackのTesterを使って生成したURLですが、実際にはAkka HTTPの Uri クラスを使ってURLを生成しています。
なので、失敗した時はどのようなURLが生成されていたかを確かめる事にしました。

で、先述の通り、 普段は成功していたのに、URLを含む場合のみ失敗した のです。
Web APIのpostMessageでは、メッセージをURLのクエリストリングに含めて送るので、やはり問題があるとすればそのあたりだろう、と当たりをつけて調べてみました。

Akka HTTPでは、クエリストリング用に Uri.Query クラスが用意されています。
今回のコードでも、クエリストリングの組み立てにはこれが使われていました。

というわけで、挙動を確かめます。

scala> Uri.Query("hoge" -> "fuga").toString
res1: String = hoge=fuga

scala> Uri.Query("hoge" -> "fuga fuga+&><piyo").toString
res2: String = hoge=fuga+fuga%2B%26%3E%3Cpiyo

いい感じに変換してくれているようですね。
URIに使えない文字列もエスケープしてくれています。

それでは、URLを含む文字列を渡してみましょう。

scala> Uri.Query("text" -> "http://example.com?hoge=fuga").toString
res3: String = text=http://example.com?hoge%3Dfuga

……あれっ?

上のSlackのTesterがエンコードしたものと比較してみましょうか。

SlackのTester
http%3A%2F%2Fexample.com%3Fhoge%3Dfuga

Akka HTTP Uri.Query
http://example.com?hoge%3Dfuga

:/? をエスケープしてくれていないですね。

原因は ?

これが原因なのでしょうか。
curlで :/? をパーセントエンコードしないURLを叩いて確かめてみましょう。

$ curl "https://slack.com/api/chat.postMessage?token=___&channel=___&text=http://example.com?hoge%3Dfuga"
404 File Not Found

はい、間違いないようですね。

では、 :/? のどれが、あるいは全てが、原因なのでしょうか?
タイトルでネタバレしている通り、悪いのは ? でした。

$ curl "https://slack.com/api/chat.postMessage?token=___&channel=___&text=http://example.com%3Fhoge%3Dfuga"
{"ok":true,"channel":"___","ts":"___","message":{"text":"<http:\/\/example.com?hoge=fuga>","username":"Slack API Tester","bot_id":"___","type":"message","subtype":"bot_message","ts":"___"}}
$ curl "https://slack.com/api/chat.postMessage?token=___&channel=___&text=http%3A%2F%2Fexample.com?hoge%3Dfuga"
404 File Not Found

これは、 ? をパーセントエンコードしないAkka HTTPが悪いのでしょうか?

Akka HTTPのコード

Akka HTTPでクエリストリングをレンダリングしているのはこの辺です。
そして、ここで文字列をエンコードするのに、encodeメソッドkeep = `strict-query-char-np` という引数で呼んでいます。
encodeメソッドは、どうやら keep に含まれる文字はそのまま、それ以外をパーセントエンコードするようです。

つまり、 strict-query-char-np にどのような文字が含まれているかが問題になります。
そして、そのあたりはこのファイルで定義されています。

これを見ると、

  val `query-fragment-char` = `pchar-base` ++ "/?"
  val `strict-query-key-char` = `query-fragment-char` -- "&=;"
  val `strict-query-value-char` = `query-fragment-char` -- "&=;"
  val `strict-query-char-np` = `strict-query-value-char` -- '+'

というコードが見えるので、明示的に /? とは strict-query-char-np に含まれる とされている事がわかります。
つまり、 /? とを意図してパーセントエンコードしていないようです。

このファイルのコメントを引用します。

URI FRAGMENT/QUERY and PATH characters have two classes of acceptable characters: one that strictly follows rfc3986, which should be used for rendering urls, and one relaxed, which accepts all visible 7-bit ASCII characters, even if they're not percent-encoded.

この仕様は、RFC3986に準拠しているようです。

RFC3986

RFC3986の、「3.4. Query」を引用します。

3.4.  Query

   The query component contains non-hierarchical data that, along with
   data in the path component (Section 3.3), serves to identify a
   resource within the scope of the URI's scheme and naming authority
   (if any).  The query component is indicated by the first question
   mark ("?") character and terminated by a number sign ("#") character
   or by the end of the URI.

      query       = *( pchar / "/" / "?" )

   The characters slash ("/") and question mark ("?") may represent data
   within the query component.  Beware that some older, erroneous
   implementations may not handle such data correctly when it is used as
   the base URI for relative references (Section 5.1), apparently
   because they fail to distinguish query data from path data when
   looking for hierarchical separators.  However, as query components
   are often used to carry identifying information in the form of
   "key=value" pairs and one frequently used value is a reference to
   another URI, it is sometimes better for usability to avoid percent-
   encoding those characters.

はい、queryとして使える文字の中に、明示的に /? が加えられているのが見えます。

The characters slash ("/") and question mark ("?") may represent data within the query component.

との事です。RFCに準拠すれば、クエリストリングに ? を使うのは問題ないようです。

上の記載によれば、クエリストリングは「最初の ? から # またはURIの終端」という範囲を指すらしいので、クエリストリング自体の中に ? が入っていても、なるほど、曖昧性は無いわけですね。

しかし困った事に、「古い、エラーのある実装だと /? を含んだクエリを正しく取り扱えないかも」などと書いてあって、でもその下に、「とはいえ、ユーザビリティの為にはそういう文字列はパーセントエンコードしない方が良いよ」とか書いてあって、どうすればいいのやらです。

ともかくも、Akka HTTPの Uri.Query の実装は正しいものである事が分かりました。

結論

RFCに従うなら、Akka HTTPには問題は無さそうです。
stackoverflowでも、 ? はパーセントエンコードしないでも問題ないのかという問答があります。

SlackのWeb APIがこけている理由は、良くわかりません。
が、URLとしては正しいもののように見えるので、Web API側に問題があるようにも思えます。

とは言え、「RFCに準拠していないSlackのAPIが悪い」とも言えない面もあります。
というのも、上のクエリストリングに利用可能な文字の pchar の中には、 &= とが含まれています。
しかし実際には、Akka HTTPを含む多くのHTTPクライアントは、この文字を無制限には使用していません。 key1=value1&key2=value2 という形式に解釈できるように注意して、keyやvalueの中でこれらの文字列が出現する場合はパーセントエンコードしています(もっと多くの制約が付く場合もあります)。
これらは、RFCで仕様として決められているわけではありません(「しばしば "key=value" という形で利用される」と言及されてはいますが)。HTMLや、各種Webフレームワークがそれぞれ取り決めているだけです。

じゃあSlackのWeb APIの仕様だと、クエリストリング中の ? についてはどう決められているのでしょうか?
……探したのですが、仕様のようなものは見つかりませんでした(絵文字や & < > などのURLエンコードについての記述はあったのですが)。
しかし、クエリストリング中に ? が出現しても解釈に曖昧性が発生する事は無いと思います。
またRFCには、パーセントエンコードしない事を推奨する旨の記述もあります(上述)。

結局この問題は、 ? を独自にパーセントエンコードする事で解決しました。
しかし400とかじゃなくて 404 File Not Found というエラーが返って来たのはどういう理由なんでしょうね?(調べてない)

(追記)application/x-www-form-urlencoded

と、SlackのAPIが良くない的な事を書いてしまったのですが、APIのドキュメントを読み直していたら、

Present these parameters as part of an application/x-www-form-urlencoded querystring or POST body. application/json is not currently accepted.

という風に、きちんとエンコードの形式が指定されていました。
完全に見落としていました。申し訳ありません。

では、 application/x-www-form-urlencoded とはどのような形式なのでしょうか?

仕様は、こちらのようです。名前と値とを = でペアとし、 & で区切って複数個並べて送る、よく見る形式のあれですね。

この仕様書の中に、パース方法やシリアライズ方法も詳しく規定されていました。
その中の、バイトシリアライザ(各名前や値のシリアライズ方法)を引用します。見てみましょう。

The application/x-www-form-urlencoded byte serializer takes a byte sequence input and then runs these steps:

1. Let output be the empty string.

2. For each byte in input, depending on byte:

   0x20 (SP)
   Append U+002B (+) to output.

   0x2A (*)
   0x2D (-)
   0x2E (.)
   0x30 (0) to 0x39 (9)
   0x41 (A) to 0x5A (Z)
   0x5F (_)
   0x61 (a) to 0x7A (z)
   Append a code point whose value is byte to output.

   Otherwise
   Append byte, percent encoded, to output.

3. Return output.

どういう事かといいますと、「空白は + に置き換え」「英数文字と *-._ はそのまま使い」「 その他の文字はパーセントエンコードする 」という事です。

? は勿論、 /: もパーセントエンコードするのが正しいという事になります。

というわけで、 ? を使ってエラーが出たとしても、それはSlack Web APIの仕様上何の問題も無い事でした。
きちんと調べずに「RFC的に問題ない文字列を受け付けないAPIは良くない」といった事を書いてしまいすみませんでした。