Haskellを学ぶときの悩み
Haskellを勉強し始めたころ、ずっと困っていたことがあった。「これ、どこで使うの?」という感覚が抜けなくて、なかなか前に進めなかった。
ある時、こんな仕事が入ってきた。HTTP APIからデータを取得して、ちょっと加工して、別のエンドポイントにPOSTする、というやつ。よくある感じの作業だ。いつもならPythonとrequestsライブラリで片付けるところ。URLを叩いてJSONを受け取って、変換して送る、それだけのことが数行で書ける。
でもふと思った。これ、Haskellでやってみるのにちょうどいい題材じゃないか。
既存のライブラリを探してみた
まず試したのはhttp-client。Haskellの多くのHTTPライブラリの土台になっている、頼もしいやつだ。ただ、シンプルなことをしたいだけでも、マネージャーを作って、リクエストをパースして、低レベルな型と格闘して、と準備が多い。細かいところまで制御したい場面には向いているけど、今回の用途にはちょっと大げさだった。
軽量な選択肢もある。wreqはlens APIを使った人気のライブラリで、Haskellエコシステムの中でも独特の魅力がある。reqは型安全なAPIがきれいにまとまっていて、実際これを使って仕事を終わらせた。どちらもよくできたライブラリだ。でも、Pythonのrequestsのあのシンプルさが頭から離れなかった。Haskellでも同じような感覚で書けないかな、とずっと考えていた。
http-dispatchとの出会い、そして自作へ
しばらくしてhttp-dispatchというライブラリを見つけた。イメージにかなり近くて、APIも分かりやすかった。ただ残念なことに、長い間メンテナンスされていなかった。バグがいくつかあって、ソースコードには修正が入っているのにHackageには反映されていない状態だった。
それなら自分で作ろう、と思い立った。
目標はシンプルで、HTTPの細かい制御が不要な場面に使える軽量ライブラリ。そういう場面にはやっぱりhttp-clientが正解だけど、リクエストを投げてレスポンスを受け取るだけ、という場合に、もっとPythonっぽい感覚で書けるものが欲しかった。
核となる抽象
HTTPのやりとりを一番シンプルに表すとこうなる。リクエストを作って、サーバーに送って、レスポンスを受け取る。
これをそのままAPIにした。
data Request a = Request
{ method :: Method
, url :: String
, headers :: Headers
, body :: Maybe a
}
data Response a = Response
{ status :: Int
, headers :: Headers
, body :: a
}
send :: (ToRequestBody a, FromResponseBody b) => Request a -> IO (Response b)
sendはRequestを受け取ってIOの中でResponseを返す。型パラメータがボディの型を運んで、型クラスの制約がシリアライズ・デシリアライズを自動でやってくれる。
よく使うHTTPメソッドにはショートカットも用意した。
-- シンプルなGETリクエスト
resp <- get "https://example.com" :: IO (Response String)
print resp.status -- 200
print resp.body -- HTMLがStringで取れる
-- テキストをPOST
resp <- post "https://httpbin.org/post" ("hello" :: String) :: IO (Response String)
print resp.status -- 200
カスタムヘッダーが必要なときはRequestを直接組み立てればいい。
let req = Request
{ method = GET
, url = "https://api.example.com/data"
, headers = [("Authorization", "Bearer my-token")]
, body = Nothing :: Maybe BS.ByteString
}
resp <- send req :: IO (Response String)
モダンなレコードドット構文(resp.status、resp.body)も、昔ながらのアクセサ関数(responseStatus resp、responseBody resp)も両方使える。
JSONサポート
Pythonのrequestsで一番気に入っていたのが、JSONの扱いやすさだ。json=でリクエストボディを渡して、.json()でレスポンスをパースする。Content-Typeヘッダーも、シリアライズも、デシリアライズも、全部向こうがやってくれる。API開発の9割はこれで事足りると思う。
Haskellの型システムがあると、これをさらに気持ちよく実現できる。データ型を事前に宣言しておくことで、レスポンスの形について静的な保証が得られるからだ。
JSONレスポンスをパースする例はこんな感じ。
{-# LANGUAGE DeriveGeneric #-}
import Data.Aeson (FromJSON)
import GHC.Generics (Generic)
import Network.HTTP.Request
data Date = Date
{ __type :: String
, iso :: String
} deriving (Show, Generic)
instance FromJSON Date
main :: IO ()
main = do
response <- get "https://api.leancloud.cn/1.1/date" :: IO (Response Date)
print response.status -- 200
print response.body -- Date { __type = "Date", iso = "2026-03-14T..." }
明示的なパース処理は不要で、:: IO (Response Date)という型注釈を書くだけでライブラリがJSONをDate型にデコードしてくれる。デコードに失敗した場合はAesonExceptionが投げられる。
JSONを送る側も同じ要領だ。
data Message = Message { content :: String } deriving (Generic)
instance ToJSON Message
main :: IO ()
main = do
resp <- post "https://api.example.com/messages" (Message "Hello")
:: IO (Response String)
print resp.status
ToJSONインスタンスを持つ型は自動でJSONにシリアライズされて、Content-Type: application/jsonも勝手にセットされる。気にしなくていい。
Pythonのrequestsのシンプルさと、静的型システムのコンパイル時保証が両立できている。API仕様を型として定義しておけば、コンパイラが正しく使えているかを確認してくれる。
LLMエージェントに使ってみた
ライブラリが動くようになったので、もう少し本格的なものを作りたくなった。LLMエージェントを実装してみようと思って、Claudeと会話できるCLIツールhasukeを作ることにした。
AnthropicスタイルのAPIを呼ぶのは、まさにこのライブラリのためにあるような用途だ。JSONのリクエストボディを組んで、エンドポイントに送って、JSONのレスポンスを受け取る。ライブラリはすんなり動いてくれた。
ところが最初のバージョンを作ったとき、困ったことに気づいた。Claudeが全部の返答を生成し終わるまで、ずっと無言で待ち続けて、最後に一気に表示する。短い返答なら気にならないけど、長い返答だと体験がよくない。最近のLLMプロバイダーはこの問題に対応するためにストリーミングレスポンスをサポートしていて、Server-Sent Eventsを使って少しずつ結果を送ってくれる。
ストリーミングとSSEのサポートを追加した
ストリーミングに対応するためにライブラリを拡張した。ポイントは、コアAPIを変えずに既存の型システムの中で表現することだった。StreamBodyという型を導入した。
data StreamBody a = StreamBody
{ readNext :: IO (Maybe a)
, closeStream :: IO ()
}
ストリーミングでレスポンスを受け取りたいときは、型注釈を変えるだけでいい。
let req = Request GET "https://example.com/stream" [] (Nothing :: Maybe BS.ByteString)
resp <- send req :: IO (Response (StreamBody BS.ByteString))
let loop = do
mChunk <- resp.body.readNext
case mChunk of
Nothing -> return ()
Just chunk -> BS.putStr chunk >> loop
loop
resp.body.closeStream
SSEの場合はさらに一歩進んで、イベントストリームのプロトコルを自動でパースしてくれる。SseEventはデータ、イベントタイプ、IDのフィールドを持っている。
data SseEvent = SseEvent
{ sseData :: T.Text
, sseType :: Maybe T.Text
, sseId :: Maybe T.Text
}
使い方はこんな感じ。
let req = Request POST "https://api.anthropic.com/v1/messages" headers (Just body)
resp <- send req :: IO (Response (StreamBody SseEvent))
let loop = do
mEvent <- resp.body.readNext
case mEvent of
Nothing -> return ()
Just event -> T.putStr event.sseData >> loop
loop
resp.body.closeStream
send関数のシグネチャは何も変わっていない。FromResponseBody型クラスが内部でうまくやってくれていて、戻り値の型がStreamBody SseEventのときはコネクションを開けたままにして、内部バッファを通じてイベントを流してくれる。呼び出す側からすると、ただレスポンスボディの種類が違うだけだ。
全く異なるデータ転送の仕組みを追加するのに、既存のAPIはほとんど触らずに済んだ。sendもRequestも変わらず、今ではhasukeの中でちゃんと動いている。
現状と今後
このライブラリはHackageにrequestという名前で公開していて、cabalやstackで普通にインストールできる。ソースコードはGitHubの https://github.com/aisk/request にある。hasuke(https://github.com/aisk/hasuke)のストリーミング出力もこのライブラリで動いている。
まだ足りていない機能もある。HTMLフォームのエンコーディングなど、対応できていないケースがいくつかある。これらは少しずつ追加していくつもりだ。何か欲しい機能があったり、問題を見つけたりしたら、気軽にGitHubでissueを立ててほしい。フィードバックも貢献も大歓迎だ。
JSONのAPIを呼ぶという、よくあるユースケースには十分対応できていると思う。Haskellを勉強中で何か手を動かすものを探している人や、軽量なHTTPクライアントが必要な人は、ぜひ試してみてほしい。