0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonのrequestsっぽいHTTPクライアントをHaskellで作った

0
Posted at

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)

sendRequestを受け取って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.statusresp.body)も、昔ながらのアクセサ関数(responseStatus respresponseBody 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はほとんど触らずに済んだ。sendRequestも変わらず、今ではhasukeの中でちゃんと動いている。

現状と今後

このライブラリはHackageにrequestという名前で公開していて、cabalやstackで普通にインストールできる。ソースコードはGitHubの https://github.com/aisk/request にある。hasuke(https://github.com/aisk/hasuke)のストリーミング出力もこのライブラリで動いている。

まだ足りていない機能もある。HTMLフォームのエンコーディングなど、対応できていないケースがいくつかある。これらは少しずつ追加していくつもりだ。何か欲しい機能があったり、問題を見つけたりしたら、気軽にGitHubでissueを立ててほしい。フィードバックも貢献も大歓迎だ。

JSONのAPIを呼ぶという、よくあるユースケースには十分対応できていると思う。Haskellを勉強中で何か手を動かすものを探している人や、軽量なHTTPクライアントが必要な人は、ぜひ試してみてほしい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?