HTTP uses TCP, so naturally this is going to be built on top of the previous post .
Goal To goal is to receive a HTTP request and respond with 200 OK where the body has information about the request received.
HTTP protocol A HTTP request has three parts:
Request line : which contains information of the method (e.g., GET, POST, PUT), the resource and the HTTP version.Headers : One by line and provide additional information.Body : Contains more data.Example:
Host: marcelofernandes.dev
Previous TCP server Convenient copy of the previous TCP server that we’re going to built on top of.
( :require [clojure.string :as str])
( :import ( java.net ServerSocket)
( java.io BufferedReader InputStreamReader PrintWriter)))
[{ :keys [in out handler]}]
( when-let [line ( .readLine in)]
( println " Received: " line)
( .println out ( handler line))
( with-open [in ( BufferedReader. ( InputStreamReader. ( .getInputStream client-socket)))
out ( PrintWriter. ( .getOutputStream client-socket) true )]
( println " Client error: " ( .getMessage e)))
( when-not ( .isClosed client-socket)
( println " Client disconnected by error " ))))))
( let [state ( atom { :server-socket nil
( when ( nil? ( :server-socket @state))
( reset! state { :server-socket ( ServerSocket. port)
( println " Server started on port " port)
( let [client-socket ( .accept ( :server-socket @state))]
( swap! state update :clients conj client-socket)
( client-handler client-socket handler)
( println " Server error: " ( .getMessage e)))
( when-not ( .isClosed ( :server-socket @state))
( .close ( :server-socket @state))
( println " Server stopped by error " )))))))
( when-let [server-socket ( :server-socket @state)]
( doseq [client-socket ( :clients @state)]
( println " Client disconnected by server " ))
( println " Server stopped by user " )
( reset! state { :server-socket nil
( let [{ :keys [start stop]} ( create-tcp-server { :port 1230
:handler str/upper-case})]
To start we can test the behavior of this server by making a request with curl
:
The logs are:
Server started on port 1230
Received: Host: localhost:1230
Received: User-Agent: curl/8.4.0
Client error: Connection reset
Client disconnected by server
Server error: Socket closed
We can see that it reads one line at a time, first getting the request line, then the headers, followed by an empty line. That doesn’t say anything about the body. Let’s try with a POST:
curl --data ' {:hello "There"} ' --header ' Content-Type: application/edn ' localhost:1230
The logs:
Server started on port 1230
Received: POST / HTTP/1.1
Received: Host: localhost:1230
Received: User-Agent: curl/8.4.0
Received: Content-Type: application/edn
Received: Content-Length: 16
Client error: Connection reset
Client disconnected by server
Server error: Socket closed
We got some extra headers but we’re not able to see the {:hello "There"}
yet.
Parse request to a useful data structure I want to transform that request into a data structure that I can work with. For the request above that would be:
:headers { " Host " " localhost:1230 "
" User-Agent " " curl/8.4.0 "
" Content-Type " " application/edn "
The handle-input
doesn’t need a handler
function anymore. To keep the REPL flow going we can structure the code like this:
( :require [clojure.string :as str])
( :import ( java.net ServerSocket)
( java.io BufferedReader InputStreamReader PrintWriter)))
( if-let [line ( .readLine in)]
( let [[method path protocol] ( str/split line #" \s +" )]
:method ( -> method str/lower-case keyword)
( println " Client disconnected " ))
( println " Still need to parse headers " )))))
( with-open [in ( BufferedReader. ( InputStreamReader. ( .getInputStream client-socket)))
out ( PrintWriter. ( .getOutputStream client-socket) true )]
( println " Client error: " ( .getMessage e)))
( when-not ( .isClosed client-socket)
( println " Client disconnected by error " ))))))
( let [state ( atom { :server-socket nil
( when ( nil? ( :server-socket @state))
( reset! state { :server-socket ( ServerSocket. port)
( println " Server started on port " port)
( let [client-socket ( .accept ( :server-socket @state))]
( swap! state update :clients conj client-socket)
( client-handler client-socket)
( println " Server error: " ( .getMessage e)))
( when-not ( .isClosed ( :server-socket @state))
( .close ( :server-socket @state))
( println " Server stopped by error " )))))))
( when-let [server-socket ( :server-socket @state)]
( doseq [client-socket ( :clients @state)]
( println " Client disconnected by server " ))
( println " Server stopped by user " )
( reset! state { :server-socket nil
( def server ( create-tcp-server { :port 1230 }))
(( :start server))) ;; to start
(( :stop server)) ;; to stop
Then we can just evaluate handle-input
any time there is a change. When receiving a new request it will use the new version of the function.
When sending a request like this:
curl --data ' {:hello "There"} ' --header ' Content-Type: application/edn ' localhost:1230
the logs show:
{:method :post, :path /, :protocol HTTP/1.1}
Still need to parse headers
Parsing the headers is a matter of reading line by line and splitting by :
.
( if-let [line ( .readLine in)]
( let [[method path protocol] ( str/split line #" \s +" )]
:method ( -> method str/lower-case keyword)
( println " Client disconnected " ))
( if-let [line ( .readLine in)]
( let [header-vector ( str/split line #": " )
request-with-new-header ( assoc-in request
[ :headers ( first header-vector)]
( recur request-with-new-header :headers )))
( println " Done parsing the request " )))))
which logs
{:method :post, :path /, :protocol HTTP/1.1, :headers {Host localhost:1230, User-Agent curl/8.4.0, Accept */*, Content-Type application/edn, Content-Length 16}}
Writing the response back to client To write the response back we just need to answer according to HTTP protocol:
In our case we won’t sent any headers and just send our structure as the body.
( if-let [line ( .readLine in)]
( let [[method path protocol] ( str/split line #" \s +" )]
:method ( -> method str/lower-case keyword)
( println " Client disconnected " ))
( if-let [line ( .readLine in)]
( let [header-vector ( str/split line #": " )
request-with-new-header ( assoc-in request
[ :headers ( first header-vector)]
( recur request-with-new-header :headers )))
( .println out " HTTP/1.1 200 OK \r\n\r\n " )
( .println out ( str request))
( println " Done parsing the request " )))))
When I open the browser and navigate to http://localhost:1230 I see this response (unformatted):
:headers {"Sec-Fetch-Dest" "document"
"Sec-Fetch-Mode" "navigate"
"Upgrade-Insecure-Requests" "1"
"User-Agent" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0"
"Accept-Encoding" "gzip, deflate, br, zstd"
"Accept-Language" "en-US,en;q=0.5"
"Connection" "keep-alive"
"Accept" "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8"
"Host" "localhost:1230"}}
Parsing the body The body is a little trickier because we can’t just use readLine
. It doesn’t have a line feed (\n) or carriage return (\r) or anything like that. Instead in this basic attempt we rely on Content-Length header.
This request:
curl --data ' {:hello "There"} ' --header ' Content-Type: application/edn ' localhost:1230
Has a content length of 16.
Then we can check if the content length header is present, make a huge assumption that it’s telling the truth, and read that many characters:
( if-let [line ( .readLine in)]
( let [[method path protocol] ( str/split line #" \s +" )]
:method ( -> method str/lower-case keyword)
( println " Client disconnected " ))
( if-let [line ( .readLine in)]
( let [[key value] ( str/split line #": " )]
( recur ( assoc-in request [ :headers key] value) :headers )))
( let [content-length ( get-in request [ :headers " Content-Length " ])]
( let [size ( parse-long content-length)
( aset body i ( char ( .read in))))
( recur ( assoc request :body ( String. body)) :done ))))
( .println out " HTTP/1.1 200 OK \r\n\r\n " )
( .println out ( str request))
( println " Done parsing the request " )))))
The response for the previous request is:
:headers { " Host " " localhost:1230 "
" User-Agent " " curl/8.4.0 "
" Content-Type " " application/edn "
:body " {:hello \" There \" } " }
Conclusion The complete code is this:
( :require [clojure.string :as str])
( :import ( java.net ServerSocket)
( java.io BufferedReader InputStreamReader PrintWriter)))
( if-let [line ( .readLine in)]
( let [[method path protocol] ( str/split line #" \s +" )]
:method ( -> method str/lower-case keyword)
( println " Client disconnected " ))
( if-let [line ( .readLine in)]
( let [[key value] ( str/split line #": " )]
( recur ( assoc-in request [ :headers key] value) :headers )))
( let [content-length ( get-in request [ :headers " Content-Length " ])]
( let [size ( parse-long content-length)
( aset body i ( char ( .read in))))
( recur ( assoc request :body ( String. body)) :done ))))
( .println out " HTTP/1.1 200 OK \r\n\r\n " )
( .println out ( str request))
( println " Done parsing the request " )))))
( with-open [in ( BufferedReader. ( InputStreamReader. ( .getInputStream client-socket)))
out ( PrintWriter. ( .getOutputStream client-socket) true )]
( println " Client error: " ( .getMessage e)))
( when-not ( .isClosed client-socket)
( println " Client disconnected by error " ))))))
( let [state ( atom { :server-socket nil
( when ( nil? ( :server-socket @state))
( reset! state { :server-socket ( ServerSocket. port)
( println " Server started on port " port)
( let [client-socket ( .accept ( :server-socket @state))]
( swap! state update :clients conj client-socket)
( client-handler client-socket)
( println " Server error: " ( .getMessage e)))
( when-not ( .isClosed ( :server-socket @state))
( .close ( :server-socket @state))
( println " Server stopped by error " )))))))
( when-let [server-socket ( :server-socket @state)]
( doseq [client-socket ( :clients @state)]
( println " Client disconnected by server " ))
( println " Server stopped by user " )
( reset! state { :server-socket nil
( def server ( create-tcp-server { :port 1230 }))
It’s full of problems and security issues. But it’s a nice start for creating a basic HTTP server using only the Socket interface.