When I started learning Haskell, I always struggled to find a suitable scenario to apply it. Without a concrete problem to solve, it is difficult to make real progress.
At some point I had some practical work to do: fetch data from HTTP
APIs, process the results, and post them to other endpoints. My instinct
was to reach for Python and the requests library, because
it is genuinely handy for this kind of task. You grab a URL, get back
some JSON, transform it, and send it somewhere else. The whole thing can
be done in a few lines.
But I thought: this is exactly the kind of real problem I need. Why not try Haskell?
The first thing I reached for was http-client. It is
powerful and the foundation for most other Haskell HTTP libraries. But
using it directly requires quite a bit of setup for simple tasks. You
need to create a manager, parse a request, and work with a set of
low-level types. It is a great library, but it is designed for cases
where you need precise control.
There are some lighter alternatives. wreq is a popular
one, built on a lens API. Lenses are a fascinating part of the Haskell
ecosystem and many people find them worth learning. req is
another option with a clean, type-safe API. I used req to
finish the work I was doing, and it worked well. But I still missed the
simplicity of Python’s requests, and I kept thinking about
whether something like it was possible in Haskell.
Some time later, I came across http-dispatch. It was
much closer to what I had in mind. The API was straightforward and the
concepts were familiar. Unfortunately the library had not been
maintained for a long time. There were some bugs, and some fixes in the
source code had never made it to Hackage.
That is when I decided to build my own.
The goal was simple: a library for scenarios where you do not need
fine-grained control over the HTTP lifecycle. For those cases,
http-client is the right tool. But for the common case of
making a request and getting back a response, I wanted something that
felt closer to the Python experience.
I started from the most basic mental model of an HTTP interaction: you have a request, you send it to a server, and you get back a response.
This became the core 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)The send function takes a Request and
produces a Response in IO. The type parameters
carry the body types, and the typeclass constraints handle serialization
and deserialization automatically.
For common HTTP methods, there are simple shortcuts:
-- A simple GET request
resp <- get "https://example.com" :: IO (Response String)
print resp.status -- 200
print resp.body -- HTML content as String
-- POST with a plain text body
resp <- post "https://httpbin.org/post" ("hello" :: String) :: IO (Response String)
print resp.status -- 200You can also construct a Request manually when you need
custom headers:
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)The library supports modern Haskell record dot syntax
(resp.status, resp.body) as well as
traditional accessor functions (responseStatus resp,
responseBody resp) for those who prefer not to enable
language extensions.
The feature I most wanted to replicate from Python’s
requests is its JSON handling. In Python, you pass
json= to your request and call .json() on the
response. It handles the content type header, the serialization, and the
deserialization for you. Most API work is exactly this: send JSON,
receive JSON.
Haskell’s type system makes this possible in a way that is, I think, even nicer than Python. Because we declare our data types upfront, we get static guarantees about the shape of the data.
Here is an example of parsing a JSON response:
{-# 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..." }There is no explicit parsing step. The type annotation
:: IO (Response Date) is enough to tell the library to
decode the JSON body into a Date value. If decoding fails,
an AesonException is thrown.
Sending JSON works the same way:
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.statusAny type with a ToJSON instance is automatically
serialized, and Content-Type: application/json is set on
the request. You do not need to think about it.
This gives you the simplicity of Python’s requests
combined with the compile-time guarantees of a static type system. You
define your API shapes as types, and the compiler helps you use them
correctly.
With the library in a working state, I wanted to build something more
substantial with it. I decided to implement a small LLM agent. That
became hasuke, a CLI tool for interacting with Claude.
Calling an Anthropic-style API is exactly the use case this library was built for. You construct a JSON request body, send it to the endpoint, and get back a JSON response. The library handled all of this without any friction.
But after building the first version, I noticed that it would sit silently until the full response was generated, then display everything at once. Modern LLM providers support streaming responses to address this: they send partial results incrementally using Server-Sent Events, so you start seeing output right away.
I extended the library to support streaming. The key was to express
this within the existing type system without changing the core API. I
introduced a StreamBody type:
data StreamBody a = StreamBody
{ readNext :: IO (Maybe a)
, closeStream :: IO ()
}To receive a streaming response, you just change the type annotation:
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.closeStreamFor SSE, the library parses the event stream protocol automatically.
Each SseEvent has fields for the data, event type, and
event id:
data SseEvent = SseEvent
{ sseData :: T.Text
, sseType :: Maybe T.Text
, sseId :: Maybe T.Text
}Using it looks like this:
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.closeStreamThe send function signature did not change at all. When
the target type is StreamBody SseEvent, the library keeps
the connection open and streams events through an internal buffer. From
the caller’s side, you are just getting a different kind of response
body.
This is where Haskell’s type system earns its keep. Adding a
completely different data transfer mode required almost no changes to
the existing API. The same send function, the same
Request type, the same conventions. It is now working well
in hasuke.
The library is published on Hackage under the name
request and can be installed with cabal or stack in the
usual way. The source code is on GitHub at
https://github.com/aisk/request. It powers the streaming output in
hasuke, which you can find at
https://github.com/aisk/hasuke.
There are features still missing, such as support for HTML form encoding and some other less common use cases. I plan to add these gradually over time. If you run into something you need, feel free to open an issue on GitHub. Feedback and contributions are very welcome.
For the common case of calling JSON APIs, whether in a single response or as a stream, the library does what it was built to do. If you are learning Haskell and looking for a practical project, or you just need a lightweight HTTP client, it might be worth a try.