こういう名前は正しくないかもしれないが,ここでは REST API パッケージ(ライブラリ)とは,既存の REST API を走査するための Haskell パッケージのことを指してる. 例えば,既にあるものだと,GitHub APIgithubSlack APIslack-api などがある.

今回はとある事情で,ChatWork API の Haskell パッケージを req ライブラリを使って作ったので,その過程を残しておく.

ちなみに,完成品はココにある.

いきさつ

Haskell でプログラミングする(別に Haskell だけではないけど)アルバイトをしてて,そこで ChatWork API を走査する Haskell パッケージが無いから作ってと言われた. だけ.

なぜ req

他に同種の有名なものには以下のものがある.

http-client は “同種” というと語弊がある. req を含め,他の3つのライブラリのベースになるような,低レイヤーのライブラリだ. ちなみに,github ライブラリは http-client を直接使っている.

http-conduithttp-client と同じリポジトリで開発されてるだけあって,http-client の割と公式的な高レイヤーライブラリなのかもしれない. 事実多くの REST API ライブラリが http-conduit を採用している.

日本語の使い方を紹介した記事もあった.

なぜ,http-conduit を使わなかったかと言うと,conduit ありきなのに少し抵抗があったからだ. 実際に使ったことは無いので,ありきでは無いかもしれないけど

wreqslack-api で使われている. 最近の Reddit のコメントを見ると,この手のものであれば,一番有名なのだろうか?

で,実は req には「何故これらのライブラリがあるのに新しく開発したのか」が書いてあった. その中に wreq の開発が遅いから req 作った,的なことが書いてあった.

どーせ新しく作るなら,割と活発のモノの方がいいかなという(軽い)理由で req を使うことにした. もっと本音を言えば,良さそうな割にあんまり使ってる人いない感じだったので,目立つかなぁという下心もあった(笑)

作る

ずいぶん余計な話が長くなったが,ここから本題.

ちなみに

curl を使う場合は次のように書く.

XXXXX が発行したトークン.

返ってくる JSON の型を作る

https://api.chatwork.com/v2/me というエンドポイントを考える.

返ってくる JSON は次のような感じ.

コレをレコードで表現する. どう考えても account_id とか name とかが重複しそうなので適当なプレフィックスを付ける(ダサいけど…).

これを FronJSON 型クラスのインスタンスにしてやる(ToJSON はついで).

Data.Aeson.Casingaeson-casing というパッケージのモジュールで,これを利用すると簡単に aeson のための前処理を記述できる.

エンドポイント用の関数を作る

トークンを与えてエンドポイント用の関数を実行すると,先に定義した Me 型の値が返ってくる.

べた書きはあんまりよくないが,baseUrl にどのエンドポイントでも変わらない,ベースとなる URL を定義しておく. req 関数の型は以下の通り

一見難しそうだが

  • methodGETPOST などの HTTPメソッド
  • Url scheme は(細かいことを気にしなければ) エンドポイントURLを表している
  • body はリクエストの本体(POSTPUT のときに用いる)
  • Proxy response はレスポンスの型を Proxy 型で指定する(JSON なのか,生の文字列なのかなど)
  • Option scheme はその他のリクエストパラメータ
  • m response 返り値の型

Url scheme 型は /:で組み立てていく. GETメソッドの場合は本体(body)を指定できない(これは HttpBpdyAllowed 型クラスで決まってる)ので NoReqBody コンストラクタを使う. Proxy response の値は既に定義されているのでそれを使う(e.g. jsonResponse). その他のリクエストパラメータにはトークンを指定したいので,mkHeader 関数で Token 型の値(まぁただの ByteString)から生成する.

これを GHCi で実行すると次のようになる

Manager を使ってカスタマイズ

ChatWork のエンドポイントのいくつかは配列が返ってくることがある.

例えば,https://api.chatwork.com/v2/contactsでは次のようなJSONが返ってくる.

問題は,配列が空の場合. その場合,[] ではなく,なにも返ってこない… そのため,次のようなエラーで落ちてしまう.

aeson のパーサーは空文字 "" をパースできないのだ… 一度は完全に詰んだかと思ったが,たまたま作ってた当時の翌週に req パッケージが v3.0 にアップデートし,req' と言う関数が追加された!

変わったのは5引数目の (L.Request -> L.Manager -> m a) 型(もともとは Proxy response 型). ざっくりいうと,http-client パッケージの Manager 型を使って,様々な前処理を書き加えることができるようになる(他の用途もあるだろうが).

これで,空文字だったら [] に変化する前処理を加えてあげれば良い.

import Control.Monad.IO.Class (MonadIO (..))
import Data.Default.Class (def)
import Data.List (lookup)
import Data.Maybe (fromMaybe)
import Data.Proxy (Proxy (..))
import Network.Connection (initConnectionContext)
import Network.HTTP.Client (BodyReader, Manager,
                            ManagerSettings (..), Request,
                            Response (..), newManager)
import Network.HTTP.Client.Internal (constBodyReader)
import Network.HTTP.Client.TLS (mkManagerSettingsContext)
import Network.HTTP.Req (AllowsBody, HttpBody, HttpBodyAllowed,
                         HttpMethod, HttpResponse, MonadHttp, Option,
                         ProvidesBody, Url, req')
import Network.HTTP.Types.Header (hContentLength)

req ::
  ( MonadHttp m, HttpMethod method, HttpBody body, HttpResponse response
  , HttpBodyAllowed (AllowsBody method) (ProvidesBody body))
  => method
  -> Url scheme
  -> body
  -> Proxy response
  -> Option scheme
  -> m response
req method url body proxy option =
  req' method url body option (getHttpResponse' proxy)

getHttpResponse' :: (HttpResponse a, MonadHttp m) => Proxy a -> (Request -> Manager -> m a)
getHttpResponse' Proxy r _ = liftIO $ getHttpResponse r =<< fixEmptyStringManager

fixEmptyStringManager :: IO Manager
fixEmptyStringManager = do
  context <- initConnectionContext
  let settings = mkManagerSettingsContext (Just context) def Nothing
  newManager $ settings { managerModifyResponse = fixEmptyString }

fixEmptyString :: Response BodyReader -> IO (Response BodyReader)
fixEmptyString res = do
  reader <- constBodyReader ["[]"]
  let
    contentLength = fromMaybe "0" $ lookup hContentLength (responseHeaders res)
  return $ if contentLength /= "0" then res else res { responseBody = reader }

レスポンスパラメータの ContentLength0 だった場合は,"[]" を新しく返している,という処理だ. 元の Network.HTTP.Req.req 関数の代わりに,この req 関数を使えばうまく動作するはずだ.

POST や PUT はどうするか

例えば,チャットルームを作るエンドポイントの場合を考える. curl であれば次のように書く.

-d オプションを使ってルーム名 name や誰を招待するか members_member_ids を指定している. req パッケージでは次のように書く.

IconPreset 型は icon_preset パラメータに丁度対応するように作った型である(長いので割愛してる). 今回はURLエンコードする必要があるので,ReqBodyUrlEnc コンストラクタを使う. コンストラクタの引数には FormUrlEncodedParam 型の値を指定してやる必要があり,本来は (=:) 演算子を使って次のように定義する.

Int 型や Text 型だけなら楽だが,Maybe 型やリスト型のようなコンテナ型が関わってくるとめんどくさい.

なので,これを抽象化した toReqParam 型クラスを作った.

楽になった.

あとは…

あとはこれらをエンドポイントの種類だけ書く. まぁこれがしんどいんだが….

おしまい

概ねこれで完成だが,「その2」では 「API に関するエラーの場合の処理の加え方」と「自分流のテストの書き方」を書こうと思う. ちなみに,「レコードだとフィールド名の重複がつらい問題」は extensible パッケージを入れていずれ何とかしたい.