※ この情報は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は良くない」といった事を書いてしまいすみませんでした。