request

HTTP client for haskell, inspired by requests and http-dispatch.

Installation
This package is published on hackage with the same name request, you can install it with cabal or stack or nix as any other hackage packages.
Usage
This library supports modern Haskell record dot syntax. First, enable these language extensions:
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}
Then you can use the library like this:
import Network.HTTP.Request
import qualified Data.ByteString as BS
-- Using shortcuts
resp <- get "https://httpbin.org/uuid" :: IO (Response String)
print resp.status -- 200
-- Or construct a Request manually
let req = Request { method = GET, url = "https://httpbin.org/uuid", headers = [], body = () }
-- Response with ByteString body
responseBS <- send req :: IO (Response BS.ByteString)
print responseBS.status -- 200
print responseBS.body -- ByteString response
-- Response with String body
responseStr <- send req :: IO (Response String)
print responseStr.body -- String response
Core API
Request's API has three core concepts: Request record type, Response record type, send function.
Request
Request a is all about the information you will send to the target URL. The type parameter a is the body type, it can be any type that implements ToRequestBody. When send is called, the body is automatically serialized and the appropriate Content-Type header is inferred, unless you set it manually.
data Request a = Request
{ method :: Method
, url :: String
, headers :: Headers
, body :: a
} deriving (Show)
Built-in ToRequestBody instances and their inferred Content-Type:
() → empty body, no Content-Type
ByteString / lazy ByteString → application/octet-stream
Text / String → text/plain; charset=utf-8
- Any type with a
ToJSON instance → auto JSON encoding + application/json
Form a (where a has a ToForm instance) → URL-encoded + application/x-www-form-urlencoded
The Content-Type is automatically inferred from the body type. You can override it by setting the header manually:
-- Content-Type is auto-inferred from body type
send $ Request POST url [] body
-- Or override Content-Type manually
send $ Request POST url [("Content-Type", "text/xml")] xmlBytes
Response
Response is what you got from the server URL.
data Response a = Response
{ status :: Int
, headers :: Headers
, body :: a
} deriving (Show)
The response body type a can be any type that implements the FromResponse constraint, allowing flexible handling of response data. Built-in supported types include String, ByteString, Text, and any type with a FromJSON instance.
String and Text bodies are decoded with the charset declared in the response's Content-Type header, so a text/html; charset=GBK page comes back as proper text. Invalid bytes are replaced with U+FFFD. When the charset is missing or not known to the system, the body is decoded as UTF-8.
send
Once you have constructed your own Request record, you can call the send function to send it to the server. It automatically serializes the body and infers the Content-Type header. The send function's type is:
send :: (ToRequestBody a, FromResponse b) => Request a -> IO (Response b)
JSON Support
JSON Response
For any type with a FromJSON instance, the response body will be automatically decoded:
{-# LANGUAGE DeriveGeneric #-}
import Network.HTTP.Request
import Data.Aeson (FromJSON)
import GHC.Generics (Generic)
data UUID = UUID
{ uuid :: String
} deriving (Show, Generic)
instance FromJSON UUID
main :: IO ()
main = do
response <- get "https://httpbin.org/uuid" :: IO (Response UUID)
print response.status -- 200
print response.body -- UUID { uuid = "550e8400-e29b-41d4-a716-446655440000" }
If JSON decoding fails, an AesonException will be thrown, which can be caught with Control.Exception.catch or try.
JSON Request Body
The post, put, and patch shortcuts accept any type that implements ToRequestBody. For types with a ToJSON instance, the body is automatically JSON-encoded and Content-Type: application/json is set:
{-# LANGUAGE DeriveGeneric #-}
import Network.HTTP.Request
import Data.Aeson (ToJSON)
import GHC.Generics (Generic)
data User = User { name :: String } deriving (Show, Generic)
instance ToJSON User
main :: IO ()
main = do
response <- post "https://httpbin.org/post" (User "Alice") :: IO (Response String)
print response.status -- 200
For application/x-www-form-urlencoded requests (login forms, OAuth token endpoints, classic web APIs), wrap your body in the Form newtype. The Content-Type is set automatically and values are percent-encoded.
From a list of pairs
import Network.HTTP.Request
main :: IO ()
main = do
response <- post "https://httpbin.org/post"
(Form [("username", "alice"), ("password", "s3cret")])
:: IO (Response String)
print response.status -- 200
-- Body sent: username=alice&password=s3cret
Values are ByteString keys and values. Special characters (spaces, Unicode, reserved chars) are percent-encoded for you:
post "https://api.example.com/search"
(Form [("q", "hello world"), ("lang", "zh-CN")])
-- Body sent: q=hello%20world&lang=zh-CN
From a custom type
For your own record types, define a ToForm instance. This mirrors the ToJSON pattern:
import Network.HTTP.Request
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
data Login = Login
{ username :: T.Text
, password :: T.Text
}
instance ToForm Login where
toForm l = [ ("username", T.encodeUtf8 l.username)
, ("password", T.encodeUtf8 l.password)
]
main :: IO ()
main = do
response <- post "https://api.example.com/login"
(Form (Login "alice" "s3cret"))
:: IO (Response String)
print response.status
The two new pieces of API
class ToForm a where
toForm :: a -> [(ByteString, ByteString)]
newtype Form a = Form a
The Form newtype is required to disambiguate the form-encoding path from JSON. Without it, a type that has both ToJSON and ToForm instances would be ambiguous; with it, post url x always means JSON and post url (Form x) always means form.
Shortcuts
As you expected, there are some shortcuts for the most used scenarios.
get :: (FromResponse a) => String -> IO (Response a)
delete :: (FromResponse a) => String -> IO (Response a)
post :: (ToRequestBody a, FromResponse b) => String -> a -> IO (Response b)
put :: (ToRequestBody a, FromResponse b) => String -> a -> IO (Response b)
patch :: (ToRequestBody a, FromResponse b) => String -> a -> IO (Response b)
These shortcuts' definitions are simple and direct. You are encouraged to add your own if the built-in does not match your use cases, like add custom headers in every request.
Query Parameters
addQuery appends query parameters to a URL and takes care of the escaping:
let url = "https://api.example.com/search" `addQuery` [("q", "haskell request"), ("page", "2")]
response <- get url :: IO (Response String)
-- GET https://api.example.com/search?q=haskell%20request&page=2
It is a plain String -> [(Text, Text)] -> String function, so it works with Request and every shortcut. Parameters already in the URL are kept.
Authentication
basicAuth builds the value of a Basic Authorization header. Put it in the request's header list yourself:
let req = Request GET url [("Authorization", basicAuth "username" "password")] ()
response <- send req :: IO (Response String)
Checking Response Status
A response with a 4xx or 5xx status is returned as-is. If you prefer to treat error statuses as exceptions, like raise_for_status in Python requests, pass the response through raiseForStatus:
resp <- get "https://httpbin.org/status/404" >>= raiseForStatus :: IO (Response String)
-- throws: StatusException 404 [("Content-Type", ...), ...]
raiseForStatus returns the response unchanged when the status is below 400, and throws a StatusException carrying the status code and the response headers otherwise:
data StatusException = StatusException Int Headers
raiseForStatus :: Response a -> IO (Response a)
Network Errors
Connection failures, timeouts and invalid URLs are reported as http-client's HttpException. It is re-exported together with HttpExceptionContent, so you can catch it without depending on http-client yourself:
import Control.Exception (try)
import Network.HTTP.Request
main :: IO ()
main = do
result <- try (get "https://example.invalid") :: IO (Either HttpException (Response String))
case result of
Left (HttpExceptionRequest _ content) -> print content -- e.g. ConnectionFailure ...
Left (InvalidUrlException url reason) -> putStrLn (url <> ": " <> reason)
Right resp -> print resp.status
Without Language Extensions
If you prefer not to use the language extensions, you can still use the library with the traditional syntax:
- Create requests using positional arguments:
Request GET "url" [] ()
- Use prefixed accessor functions:
responseStatus response, responseHeaders response, etc.
import Network.HTTP.Request
-- Construct a Request using positional arguments
let req = Request GET "https://httpbin.org/uuid" [] ()
-- Send it
res <- send req :: IO (Response String)
-- Access the fields using prefixed accessor functions
print $ responseStatus res
Custom Connection Manager
By default, send uses the http-client global TLS manager. For most applications this is fine, you get connection pooling for free with no setup. If you want to isolate your library's connection pool from the rest of the program, keep a long-lived manager in a service, or configure proxies and custom TLS settings, create your own manager and pass it to sendWith:
import Network.HTTP.Request
main :: IO ()
main = do
mgr <- newManager
resp <- sendWith mgr (Request GET "https://api.example.com/things" [] ()) :: IO (Response String)
print resp.status
The two new pieces of API:
newManager :: IO Manager
sendWith :: (ToRequestBody a, FromResponse b) => Manager -> Request a -> IO (Response b)
Manager is the same type as Network.HTTP.Client.Manager, re-exported for convenience. For deeper configuration (ManagerSettings, custom proxies, certificate pinning, etc.) import Network.HTTP.Client / Network.HTTP.Client.TLS directly and build a Manager however you need. sendWith accepts it as-is.
Timeouts
Requests time out after 30 seconds by default, which is the http-client default. The timeout covers connecting and waiting for the response headers, not reading the body, so long-lived streams are not cut off. To change it, build a manager with a different managerResponseTimeout (in microseconds). This needs http-client and http-client-tls in your build-depends:
import Network.HTTP.Request
import qualified Network.HTTP.Client as HC
import qualified Network.HTTP.Client.TLS as TLS
main :: IO ()
main = do
mgr <- HC.newManager TLS.tlsManagerSettings
{ HC.managerResponseTimeout = HC.responseTimeoutMicro 5000000 } -- 5 seconds
resp <- sendWith mgr (Request GET "https://api.example.com/things" [] ()) :: IO (Response String)
print resp.status
Use HC.responseTimeoutNone to disable the timeout. A timed out request throws HttpExceptionRequest with ResponseTimeout or ConnectionTimeout.
To apply the same setting to send and the shortcut functions, install the manager globally with TLS.setGlobalManager mgr.
Streaming Support
For large responses or real-time data, you can stream the response body instead of buffering it all in memory.
Raw Byte Chunks
Use StreamBody BS.ByteString to receive the response body as a stream of raw byte chunks:
import Network.HTTP.Request
import qualified Data.ByteString as BS
main :: IO ()
main = do
let req = Request GET "https://example.com/large-file" [] ()
resp <- send req :: IO (Response (StreamBody BS.ByteString))
print resp.status -- 200
let loop = do
mChunk <- resp.body.readNext
case mChunk of
Nothing -> return () -- stream finished
Just chunk -> do
BS.putStr chunk
loop
loop
resp.body.closeStream
SSE (Server-Sent Events)
Use StreamBody SseEvent to automatically parse an SSE stream. Each call to readNext returns the next complete event:
import Network.HTTP.Request
import qualified Data.Text.IO as T
data SseEvent = SseEvent
{ sseData :: T.Text -- content of the "data:" field
, sseType :: Maybe T.Text -- content of the "event:" field
, sseId :: Maybe T.Text -- content of the "id:" field
}
main :: IO ()
main = do
let req = Request GET "https://example.com/events" [] ()
resp <- send req :: IO (Response (StreamBody SseEvent))
print resp.status -- 200
let loop = do
mEvent <- resp.body.readNext
case mEvent of
Nothing -> return () -- stream finished
Just event -> do
T.putStrLn event.sseData
loop
loop
resp.body.closeStream
StreamBody has two fields:
readNext :: IO (Maybe a) — reads the next chunk or event; returns Nothing when the stream ends
closeStream :: IO () — closes the underlying connection
Custom Response Types
To support your own response body type, implement FromResponse. Its single method receives the response before the body has been read:
class FromResponse a where
fromResponse :: Response (StreamBody ByteString) -> IO a
Most instances just want the whole body. decodeResponse buffers it, closes the connection and runs a pure decoder that can also look at the status and headers. A Left is thrown as ResponseBodyException:
import Network.HTTP.Request
import qualified Data.ByteString.Lazy.Char8 as LBS
newtype Lines = Lines [LBS.ByteString]
instance FromResponse Lines where
fromResponse = decodeResponse $ \res ->
if res.status < 400
then Right (Lines (LBS.lines res.body))
else Left ("unexpected status " <> show res.status)
The two helpers:
bufferResponse :: Response (StreamBody ByteString) -> IO (Response LazyByteString)
decodeResponse :: (Response LazyByteString -> Either String a) -> Response (StreamBody ByteString) -> IO a
Use bufferResponse when you need IO or want to throw your own exception type. An instance that neither calls these helpers nor returns the stream to the caller must call closeStream itself.
API Documents
See the hackage page: http://hackage.haskell.org/package/request/docs/Network-HTTP-Request.html
About the Project
Request is © 2020-2026 by AN Long.
License
Request is distributed by a BSD license.