fnl-http inside look
An HTTP client for Fennel written from scratch
- Supports HTTP/1.1
- Async operations
- Streaming support
- Sever implementation included
Installation & Usage
Via deps:
;; deps.fnl
{:deps
{:io.gitlab.andreyorst/fnl-http
{:type :git :sha "20fb5964c095d4b654a993bd4edf4a187be5814e"}}}
Usage:
;; main.fnl
(local {: client : json : readers : url}
(require :io.gitlab.andreyorst.fnl-http))
Quick Overview
Main module provides:
- Basic HTTP methods:
getpostputpatchoptionstraceheaddeleteconnect
- JSON support
encodedecode
- Readers for data streaming
file-readerstring-readerltn12-reader
- URL
URLparse-urlurlencode
- Asyncronous HTTP/1.1 server
Quick Overview: Client
- Simple
GETrequest
(client.get "http://httpbin.org/get")
{:body
"{
\"args\": {},
\"headers\": {
\"Host\": \"httpbin.org\",
\"X-Amzn-Trace-Id\": \"Root=1-69502e62-09eb17c1577018c44a63ae39\"
},
\"url\": \"http://httpbin.org/get\"
}
"
:headers
{:Access-Control-Allow-Credentials "true"
:Access-Control-Allow-Origin "*"
:Connection "keep-alive"
:Content-Length "198"
:Content-Type "application/json"
:Date "Sat, 27 Dec 2025 19:07:14 GMT"
:Server "gunicorn/19.9.0"}
:http-client #<tcp-client: 0xb75126980>
:length 198
:protocol-version {:major 1 :minor 1 :name "HTTP"}
:reason-phrase "OK"
:request-time 128
:status 200
:trace-redirects {}}
GETrequest with body parsing
(client.get "http://httpbin.org/get" {:as :json})
{:body {:args {}
:headers
{:Host "httpbin.org"
:X-Amzn-Trace-Id "Root=1-69502e67-30e92b046c210466678b579a"}
:url "http://httpbin.org/get"}
:headers
{:Access-Control-Allow-Credentials "true"
:Access-Control-Allow-Origin "*"
:Connection "keep-alive"
:Content-Length "198"
:Content-Type "application/json"
:Date "Sat, 27 Dec 2025 19:07:19 GMT"
:Server "gunicorn/19.9.0"}
:http-client #<tcp-client: 0xb7537dc80>
:length 198
:protocol-version {:major 1 :minor 1 :name "HTTP"}
:reason-phrase "OK"
:request-time 149
:status 200
:trace-redirects {}}
GETrequest with body as a stream
(client.get "http://httpbin.org/get" {:as :stream})
{:body #<Reader: 0xb75123f00>
:headers
{:Access-Control-Allow-Credentials "true"
:Access-Control-Allow-Origin "*"
:Connection "keep-alive"
:Content-Length "198"
:Content-Type "application/json"
:Date "Sat, 27 Dec 2025 19:07:23 GMT"
:Server "gunicorn/19.9.0"}
:http-client #<tcp-client: 0xb7514ebc0>
:length 198
:protocol-version {:major 1 :minor 1 :name "HTTP"}
:reason-phrase "OK"
:request-time 147
:status 200
:trace-redirects {}}
- Simple
POSTrequest
(client.post
"http://httpbin.org/post"
{:body "{\"foo\": \"bar\"}"
:headers {:content-typa :application/json}
:as :json})
{:body {:args {}
:data "{\"foo\": \"bar\"}"
:files {}
:form {}
:headers
{:Content-Length "14"
:Content-Typa "application/json"
:Host "httpbin.org"
:X-Amzn-Trace-Id "Root=1-69502f27-2e169c092ee99e653f82bf5e"}
:json {:foo "bar"}
:url "http://httpbin.org/post"}
:headers
{:Access-Control-Allow-Credentials "true"
:Access-Control-Allow-Origin "*"
:Connection "keep-alive"
:Content-Length "368"
:Content-Type "application/json"
:Date "Sat, 27 Dec 2025 19:10:31 GMT"
:Server "gunicorn/19.9.0"}
:http-client #<tcp-client: 0xb75308840>
:length 368
:protocol-version {:major 1 :minor 1 :name "HTTP"}
:reason-phrase "OK"
:request-time 139
:status 200
:trace-redirects {}}
Multipart support
- Multipart can be sent:
(let [file (io.open "deps.fnl" :r)
file-size (file:seek :end)
_ (file:seek :set)
text-to-stream "streamed text"]
(-> (client.post "http://httpbin.org/post"
{:as :json
:multipart
[{:name "text" :content "text data"}
{:name "text-stream"
:content (readers.string-reader text-to-stream)
:length (length text-to-stream)}
{:name "file"
:content file
:length file-size
:filename "deps.fnl"
:mime-type "text/plain"}]})
(. :body)))
{:args {}
:data ""
:files
{:file
";; deps.fnl
{:deps
{:io.gitlab.andreyorst/fnl-http
{:type :git :sha \"20fb5964c095d4b654a993bd4edf4a187be5814e\"}}}
"}
:form {:text "text data" :text-stream "streamed text"}
:headers
{:Content-Length
"794"
:Content-Type
"multipart/form-data; boundary=------------f77e6d9d-9cac-4824-98f6-d1e08cb5e1f9"
:Host
"httpbin.org"
:X-Amzn-Trace-Id
"Root=1-69502f48-26a3f99c51252b4f7ef4a423"}
:json nil
:url "http://httpbin.org/post"}
- Can be received too, but I’m lazy to write an example
Quick Overview: JSON
- Encoding
(json.encode {:foo :bar})
{"foo": "bar"}
(json.encode [1 2 3])
[1, 2, 3]
- Decoding
(json.decode "{\"foo\": \"bar\"}")
{:foo "bar"}
(json.decode "{\"foo\": null}")
{:foo nil}
- NULL handling
(json.encode (json.decode "{\"foo\": null}"))
{"foo": null}
Quick Overview: Readers
- Create a reader from a string:
(local rdr (readers.string-reader "foo\nbar\nbaz"))
rdr
#<Reader: 0xb75261040>
- Read fixed amount of bytes
(rdr:read 4)
foo\n
- Create an iterator over lines
(icollect [l (rdr:lines)] l)
["bar" "baz"]
Quick Overview: Url module
- Parses:
- scheme
- host
- port
- userinfo
- path
- query and fragment
(url.parse "http://httpbin.org/get")
{:host "httpbin.org" :path "/get" :port 80 :scheme "http"}
- Supports tostring
(tostring (url.parse "http://httpbin.org/get"))
http://httpbin.org:80/get
- Constructs URL from tables:
(url.URL {:scheme "http" :host "httpbin.org"})
http://httpbin.org
Async requests
- Async pattern is supported by specifying
async?parameter:
(var body nil)
(client.get
"http://httpbin.org/get"
{:async? true :as :json}
(fn on-success [resp]
(set body resp.body))
(fn on-error [resp]
(error resp.body)))
#<ManyToManyChannel: 0xb7523b980>
- Operations happen in the background:
(while (= nil body))
body
{:args {}
:headers
{:Host "httpbin.org"
:X-Amzn-Trace-Id "Root=1-69502947-54f1de6e7a8da8b952407dbe"}
:url "http://httpbin.org/get"}
Why async?
- Async requests:
(local {: chan : >! : <!! : take : reduce}
(require :io.gitlab.andreyorst.async))
(time
(let [n-req 10
lengths (chan n-req)]
(for [i 1 n-req]
(client.post
"http://httpbin.org/post"
{:body (.. "foo" i) :async? true}
(fn [resp] (>! lengths (length resp.body)))
#nil))
(print "total length:" (<!! (reduce #(+ $1 $2) 0 (take n-req lengths))))))
total length: 2941
Elapsed 1762.2859477997 ms
- Sequential requests:
(time
(let [n-req 10
total (faccumulate [res 0 i 1 n-req]
(let [resp (client.post
"http://httpbin.org/post"
{:body (.. "foo" i)})]
(+ res (length resp.body))))]
(print "total length:" total)))
total length: 2941
Elapsed 3597.295999527 ms
Luasocket’s HTTP vs fnl-http
- Sequential requests:
(local lhttp (require :socket.http))
(time
(let [n-req 10
total (faccumulate [res 0 i 1 n-req]
(let [resp (lhttp.request "http://httpbin.org/post" (.. "foo" i))]
(+ res (length resp))))]
(print "total length:" total)))
total length: 4041
Elapsed 5041.1369800568 ms
Look Inside
- Powered by
async.fnl - Implements sockets as bi-directional, non-blocking async channels
- Each socket is backed by two asyncronous threads
Socket Channel
- Combo channel with sender and receiver
- Puts are handled via the sender thread, takes via the receiver thread
- Sender thread
- Checks if socket is ready to accept via
select, sleeps 10ms otherwise - Sends data
- If timeout, sends remaining data after socket is ready again
- Loops until closed
- Checks if socket is ready to accept via
- Receiver thread
- Tries to read a “chunk”
- If timeout, sleeps 10ms, collects partially received data, if any
- Loops until closed
- Closing either one closes the other one
- Closing the socket closes both sender and receiver
Parser
- Implemented from scratch
- Response parser:
- response status line
- headers
- content
- multipart
- chunked
- raw
- Request parser:
- request status line
- headers
- content
- multipart
- stream
- chunked
Body reader
- Chunked reader
- Multipart reader implemented as Lua iterator
- Each part is a stream to be consumed
- Advancing the iterator consumes remaining data from the part
Server
(server.start request-handler {:host "localhost" :port 1234})
- Each client is assigned to its own asychronous thread
- Handler receives a parsed HTTP request
Server expample: SSE
(fn wrap-format-sse-event [ch]
(let [out (chan)]
(go-loop [e (<! ch)]
(if e
(do (doto out
(>! (.. "event: " (tostring event) "\n"))
(>! (.. "id: " (tostring id) "\n"))
(>! (.. "data: " (tostring data) "\n")))
(recur (<! ch)))
(close! out)))
out))
(fn wrap-heartbeat [ch timeout-ms]
(let [out (chan)]
(go-loop []
(let [tout (timeout timeout-ms)]
(match (alts! [tout ch])
[_ tout]
(do (>! out {:event "heartbeat" :data 0})
(recur))
[event ch]
(do (>! out event)
(recur))
[nil ch] (close! out))))
out))
(fn handler [{: path : method : headers : content}]
(case path
(where "/events" (= :GET method))
(let [events (chan)]
(subscribe-to-events events)
{:status 200
:headers {:content-type "text/event-stream"}
:body (-> events
(wrap-heartbeat 10_000)
wrap-format-sse-event)})
(where "/notify" (= :POST method))
(case (tonumber headers.Content-Length)
len (let [event (json.decode (content:read len))]
(register-event event)
{:status 200
:body "OK"})
_ (error "malformed Content-Lenght"))
_
{:status 405
:headers {:content-type "application/json"}
:body (json.encode {:error "Method not allowed"})}))
(server.start handler {:port 12345})
Thanks!
- Links: