Writing Clojure Webapps with Ring
Last modified: July 9, 2020
1. Introduction
Ring is a library for writing web applications in Clojure. It supports everything needed to write fully-featured web apps and has a thriving ecosystem to make it even more powerful.
In this tutorial, we'll give an introduction to Ring, and show some of the things that can we can achieve with it.
Ring isn't a framework designed for creating REST APIs, like so many modern toolkits. It's a lower-level framework to handle HTTP requests in general, with a focus on traditional web development. However, some libraries build on top of it to support many other desired application structures.
2. Dependencies
Before we can start working with Ring, we need to add it to our project. The minimum dependencies we need are:
We can add these to our Leiningen project:
:dependencies [[org.clojure/clojure "1.10.0"] [ring/ring-core "1.7.1"] [ring/ring-jetty-adapter "1.7.1"]]
We can then add this to a minimal project:
(ns ring.core (:use ring.adapter.jetty)) (defn handler [request] {:status 200 :headers {"Content-Type" "text/plain"} :body "Hello World"}) (defn -main [& args] (run-jetty handler {:port 3000}))
Here, we've defined a handler function – which we'll cover soon – which always returns the string “Hello World”. Also, we've added our main function to use this handler – it'll listen for requests on port 3000.
3. Core Concepts
Leiningen has a few core concepts around which everything builds: Requests, Responses, Handlers, and Middleware.
3.1. Requests
Requests are a representation of incoming HTTP requests. Ring represents a request as a map, allowing our Clojure application to interact with the individual fields easily. There're a standard set of keys in this map, including but not limited to:
- :uri – The full URI path.
- :query-string – The full query string.
- :request-method – The request method, one of :get, :head, :post, :put, :delete or :options.
- :headers – A map of all the HTTP headers provided to the request.
- :body – An InputStream representing the request body, if present.
Middleware may add more keys to this map as well as needed.
3.2. Responses
Similarly, responses are a representation of the outgoing HTTP responses. Ring also represents these as maps with three standard keys:
- :status – The status code to send back
- :headers – A map of all the HTTP headers to send back
- :body – The optional body to send back
As before, Middleware may alter this between our handler producing it and the final result getting sent to the client.
Ring also provides some helpers to make building the responses easier.
The most basic of these is the ring.util.response/response function, which creates a simple response with a status code of 200 OK:
ring.core=> (ring.util.response/response "Hello") {:status 200, :headers {}, :body "Hello"}
There are a few other methods that go along with this for common status codes – for example, bad-request, not-found and redirect:
ring.core=> (ring.util.response/bad-request "Hello") {:status 400, :headers {}, :body "Hello"} ring.core=> (ring.util.response/created "/post/123") {:status 201, :headers {"Location" "/post/123"}, :body nil} ring.core=> (ring.util.response/redirect "") {:status 302, :headers {"Location" ""}, :body ""}
We also have the status method that will convert an existing response to any arbitrary status code:
ring.core=> (ring.util.response/status (ring.util.response/response "Hello") 409) {:status 409, :headers {}, :body "Hello"}
We then have some methods to adjust other features of the response similarly – for example, content-type, header or set-cookie:
ring.core=> (ring.util.response/content-type (ring.util.response/response "Hello") "text/plain") {:status 200, :headers {"Content-Type" "text/plain"}, :body "Hello"} ring.core=> (ring.util.response/header (ring.util.response/response "Hello") "X-Tutorial-For" "Baeldung") {:status 200, :headers {"X-Tutorial-For" "Baeldung"}, :body "Hello"} ring.core=> (ring.util.response/set-cookie (ring.util.response/response "Hello") "User" "123") {:status 200, :headers {}, :body "Hello", :cookies {"User" {:value "123"}}}
Note that the set-cookie method adds a whole new entry to the response map. This needs the wrap-cookies middleware to process it correctly for it to work.
3.3. Handlers
Now that we understand requests and responses, we can start to write our handler function to tie it together.
A handler is a simple function that takes the incoming request as a parameter and returns the outgoing response. What we do in this function is entirely up to our application, as long as it fits this contract.
At the very simplest, we could write a function that always returns the same response:
(defn handler [request] (ring.util.response/response "Hello"))
We can interact with the request as needed as well.
For example, we could write a handler to return the incoming IP Address:
(defn check-ip-handler [request] (ring.util.response/content-type (ring.util.response/response (:remote-addr request)) "text/plain"))
3.4. Middleware
Middleware is a name that's common in some languages but less so in the Java world. Conceptually they are similar to Servlet Filters and Spring Interceptors.
In Ring, middleware refers to simple functions that wrap the main handler and adjusts some aspects of it in some way. This could mean mutating the incoming request before it's processed, mutating the outgoing response after it's generated or potentially doing nothing more than logging how long it took to process.
In general, middleware functions take a first parameter of the handler to wrap and returns a new handler function with the new functionality.
The middleware can use as many other parameters as needed. For example, we could use the following to set the Content-Type header on every response from the wrapped handler:
(defn wrap-content-type [handler content-type] (fn [request] (let [response (handler request)] (assoc-in response [:headers "Content-Type"] content-type))))
Reading through it we can see that we return a function that takes a request – this's the new handler. This will then call the provided handler and then return a mutated version of the response.
We can use this to produce a new handler by simply chaining them together:
(def app-handler (wrap-content-type handler "text/html"))
Clojure also offers a way to chain many together in a more natural way – by the use of Threading Macros. These are a way to provide a list of functions to call, each with the output of the previous one.
In particular, we want the Thread First macro, ->. This will allow us to call each middleware with the provided value as the first parameter:
(def app-handler (-> handler (wrap-content-type "text/html") wrap-keyword-params wrap-params))
This has then produced a handler that's the original handler wrapped in three different middleware functions.
4. Writing Handlers
Now that we understand the components that make up a Ring application, we need to know what we can do with the actual handlers. These are the heart of the entire application and is where the majority of the business logic will go.
We can put whatever code we wish into these handlers, including database access or calling other services. Ring gives us some additional abilities for working directly with the incoming requests or outgoing responses that are very useful as well.
4.1. Serving Static Resources
One of the simplest functions that any web application can perform is to serve up static resources. Ring provides two middleware functions to make this easy – wrap-file and wrap-resource.
The wrap-file middleware takes a directory on the filesystem. If the incoming request matches a file in this directory then that file gets returned instead of calling the handler function:
(use 'ring.middleware.file)
(def app-handler (wrap-file your-handler "/var/www/public"))
In a very similar manner, the wrap-resource middleware takes a classpath prefix in which it looks for the files:
(use 'ring.middleware.resource)
(def app-handler (wrap-resource your-handler "public"))
In both cases, the wrapped handler function is only ever called if a file isn't found to return to the client.
Ring also provides additional middleware to make these cleaner to use over the HTTP API:
(use 'ring.middleware.resource 'ring.middleware.content-type 'ring.middleware.not-modified) (def app-handler (-> your-handler (wrap-resource "public") wrap-content-type wrap-not-modified)
The wrap-content-type middleware will automatically determine the Content-Type header to set based on the filename extension requested. The wrap-not-modified middleware compares the If-Not-Modified header to the Last-Modified value to support HTTP caching, only returning the file if it's needed.
4.2. Accessing Request Parameters
When processing a request, there are some important ways that the client can provide information to the server. These include query string parameters – included in the URL and form parameters – submitted as the request payload for POST and PUT requests.
Before we can use parameters, we must use the wrap-params middleware to wrap the handler. This correctly parses the parameters, supporting URL encoding, and makes them available to the request. This can optionally specify the character encoding to use, defaulting to UTF-8 if not specified:
(def app-handler (-> your-handler (wrap-params {:encoding "UTF-8"}) ))
Once done, the request will get updated to make the parameters available. These go into appropriate keys in the incoming request:
- :query-params – The parameters parsed out of the query string
- :form-params – The parameters parsed out of the form body
- :params – The combination of both :query-params and :form-params
We can make use of this in our request handler exactly as expected.
(defn echo-handler [{params :params}] (ring.util.response/content-type (ring.util.response/response (get params "input")) "text/plain"))
This handler will return a response containing the value from the parameter input.
Parameters map to a single string if only one value is present, or to a list if multiple values are present.
For example, we get the following parameter maps:
// /echo?input=hello {"input "hello"} // /echo?input=hello&name=Fred {"input "hello" "name" "Fred"} // /echo?input=hello&input=world {"input ["hello" "world"]}
4.3. Receiving File Uploads
Often we want to be able to write web applications that users can upload files to. In the HTTP protocol, this is typically handled using Multipart requests. These allow for a single request to contain both form parameters and a set of files.
Ring comes with a middleware called wrap-multipart-params to handle this kind of request. This is similar to the way that wrap-params parses simple requests.
wrap-multipart-params automatically decodes and stores any uploaded files onto the file system and tells the handler where they are for it to work with them:
(def app-handler (-> your-handler wrap-params wrap-multipart-params ))
By default, the uploaded files get stored in the temporary system directory and automatically deleted after an hour. Note that this does require that the JVM is still running for the next hour to perform the cleanup.
If preferred, there's also an in-memory store, though obviously, this risks running out of memory if large files get uploaded.
We can also write our storage engines if needed, as long as it fulfills the API requirements.
(def app-handler (-> your-handler wrap-params (wrap-multipart-params {:store ring.middleware.multipart-params.byte-array/byte-array-store}) ))
Once this middleware is set up, the uploaded files are available on the incoming request object under the params key. This is the same as using the wrap-params middleware. This entry is a map containing the details needed to work with the file, depending on the store used.
For example, the default temporary file store returns values:
{"file" {:filename "words.txt" :content-type "text/plain" :tempfile #object[java.io.File ...] :size 51}}
Where the :tempfile entry is a java.io.File object that directly represents the file on the file system.
4.4. Working With Cookies
Cookies are a mechanism where the server can provide a small amount of data that the client will continue to send back on subsequent requests. This is typically used for session IDs, access tokens, or persistent user data such as the configured localization settings.
Ring has middleware that will allow us to work with cookies easily. This will automatically parse cookies on incoming requests, and will also allow us to create new cookies on outgoing responses.
Configuring this middleware follows the same patterns as before:
(def app-handler (-> your-handler wrap-cookies ))
At this point, all incoming requests will have their cookies parsed and put into the :cookies key in the request. This will contain a map of the cookie name and value:
{"session_id" {:value "session-id-hash"}}
We can then add cookies to outgoing responses by adding the :cookies key to the outgoing response. We can do this by creating the response directly:
{:status 200 :headers {} :cookies {"session_id" {:value "session-id-hash"}} :body "Setting a cookie."}
There's also a helper function that we can use to add cookies to responses, in a similar way to how earlier we could set status codes or headers:
(ring.util.response/set-cookie (ring.util.response/response "Setting a cookie.") "session_id" "session-id-hash")
Cookies can also have additional options set on them, as needed for the HTTP specification. If we're using set-cookie then we provide these as a map parameter after the key and value. The keys to this map are:
- :domain – The domain to restrict the cookie to
- :path – The path to restrict the cookie to
- :secure – true to only send the cookie on HTTPS connections
- :http-only – true to make the cookie inaccessible to JavaScript
- :max-age – The number of seconds after which the browser deletes the cookie
- :expires – A specific timestamp after which the browser deletes the cookie
- :same-site – If set to :strict, then the browser won't send this cookie back with cross-site requests.
(ring.util.response/set-cookie (ring.util.response/response "Setting a cookie.") "session_id" "session-id-hash" {:secure true :http-only true :max-age 3600})
4.5. Sessions
Cookies give us the ability to store bits of information that the client sends back to the server on every request. A more powerful way of achieving this is to use sessions. These get stored entirely on the server, but the client maintains the identifier that determines which session to use.
As with everything else here, sessions are implemented using a middleware function:
(def app-handler (-> your-handler wrap-session ))
By default, this stores session data in memory. We can change this if needed, and Ring comes with an alternative store that uses cookies to store all of the session data.
As with uploading files, we can provide our storage function if needed.
(def app-handler (-> your-handler wrap-cookies (wrap-session {:store (cookie-store {:key "a 16-byte secret"})}) ))
We can also adjust the details of the cookie used to store the session key.
For example, to make it so that the session cookie persists for one hour we could do:
(def app-handler (-> your-handler wrap-cookies (wrap-session {:cookie-attrs {:max-age 3600}}) ))
The cookie attributes here are the same as supported by the wrap-cookies middleware.
Sessions can often act as data stores to work with. This doesn't always work as well in a functional programming model, so Ring implements them slightly differently.
Instead, we access the session data from the request, and we return a map of data to store into it as part of the response. This is the entire session state to store, not only the changed values.
For example, the following keeps a running count of how many times the handler has been requested:
(defn handler [{session :session}] (let [count (:count session 0) session (assoc session :count (inc count))] (-> (response (str "You accessed this page " count " times.")) (assoc :session session))))
Working this way, we can remove data from the session simply by not including the key. We can also delete the entire session by returning nil for the new map.
(defn handler [request] (-> (response "Session deleted.") (assoc :session nil)))
5. Leiningen Plugin
Ring provides a plugin for the Leiningen build tool to aid both development and production.
We set up the plugin by adding the correct plugin details to the project.clj file:
:plugins [[lein-ring "0.12.5"]] :ring {:handler ring.core/handler}
It's important that the version of lein-ring is correct for the version of Ring. Here we've been using Ring 1.7.1, which means we need lein-ring 0.12.5. In general, it's safest to just use the latest version of both, as seen on Maven central or with the lein search command:
$ lein search ring-core Searching clojars ... [ring/ring-core "1.7.1"] Ring core libraries. $ lein search lein-ring Searching clojars ... [lein-ring "0.12.5"] Leiningen Ring plugin
The :handler parameter to the :ring call is the fully-qualified name of the handler that we want to use. This can include any middleware that we've defined.
Using this plugin means that we no longer need a main function. We can use Leiningen to run in development mode, or else we can build a production artifact for deployment purposes. Our code now comes down exactly to our logic and nothing more.
5.1. Building a Production Artifact
Once this is set up, we can now build a WAR file that we can deploy to any standard servlet container:
$ lein ring uberwar 2019-04-12 07:10:08.033:INFO::main: Logging initialized @1054ms to org.eclipse.jetty.util.log.StdErrLog Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.war
We can also build a standalone JAR file that will run our handler exactly as expected:
$ lein ring uberjar Compiling ring.core 2019-04-12 07:11:27.669:INFO::main: Logging initialized @3016ms to org.eclipse.jetty.util.log.StdErrLog Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT.jar Created ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar
This JAR file will include a main class that will start the handler in the embedded container that we included. This will also honor an environment variable of PORT allowing us to easily run it in a production environment:
PORT=2000 java -jar ./clojure/ring/target/uberjar/ring-0.1.0-SNAPSHOT-standalone.jar 2019-04-12 07:14:08.954:INFO::main: Logging initialized @1009ms to org.eclipse.jetty.util.log.StdErrLog WARNING: seqable? already refers to: #'clojure.core/seqable? in namespace: clojure.core.incubator, being replaced by: #'clojure.core.incubator/seqable? 2019-04-12 07:14:10.795:INFO:oejs.Server:main: jetty-9.4.z-SNAPSHOT; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07:14:10.863:INFO:oejs.AbstractConnector:main: Started [email protected]{HTTP/1.1,[http/1.1]}{0.0.0.0:2000} 2019-04-12 07:14:10.863:INFO:oejs.Server:main: Started @2918ms Started server on port 2000
5.2. Running in Development Mode
For development purposes, we can run the handler directly from Leiningen without needing to build and run it manually. This makes things easier for testing our application in a real browser:
$ lein ring server 2019-04-12 07:16:28.908:INFO::main: Logging initialized @1403ms to org.eclipse.jetty.util.log.StdErrLog 2019-04-12 07:16:29.026:INFO:oejs.Server:main: jetty-9.4.12.v20180830; built: 2018-08-30T13:59:14.071Z; git: 27208684755d94a92186989f695db2d7b21ebc51; jvm 1.8.0_77-b03 2019-04-12 07:16:29.092:INFO:oejs.AbstractConnector:main: Started [email protected]{HTTP/1.1,[http/1.1]}{0.0.0.0:3000} 2019-04-12 07:16:29.092:INFO:oejs.Server:main: Started @1587ms
This also honors the PORT environment variable if we've set that.
Additionally, there's a Ring Development library that we can add to our project. If this is available, then the development server will attempt to automatically reload any detected source changes. This can give us an efficient workflow of changing the code and seeing it live in our browser. This requires the ring-devel dependency adding:
[ring/ring-devel "1.7.1"]
6. Conclusion
In this article, we gave a brief introduction to the Ring library as a means to write web applications in Clojure. Why not try it on the next project?
Examples of some of the concepts we've covered here can be seen in GitHub.