From Scratch - Clojure web app from the ground up
Table of Contents
- 1. Basic components
- 2. Add specialised libraries
- 3. Implementing the app
- 4. User authentication
- 5. Final comments
This article teaches you how to build a Clojure web app from scratch.
We are making a basic app, but we try to create a solid base upon-which we can build anything.
Our project will be called "polls" and implements a simple poll site. This includes using a PostreSQL database, handling form submissions, and login sessions.
We proceed through the following stages:
- First is putting together the basic components:
- we will use the Clojure CLI tools to setup a project, and demonstrate a hello world with Ring.
- we demo connecting to a PostgreSQL database with next-jdbc
- we setup state and life-cycle management with Integrant
- we use tools.build to compile the app and integrant-repl to implement our Reloaded Workflow
- We add a test runner
- Then we add more specialised libraries:
- Then we implement all the functionality of our app, demonstrating
along the way:
- session handling with ring.middleware.session
- form handling with anti-forgery tokens
- Then we add user authentication
- with buddy-hashers
I make certain assumptions about the reader:
- you already have Clojure and git installed and know how to use them
- you already have an editor setup for interactive development with Clojure
Where you see:
- 🏃: there is an exercise for you to work out yourself.
- ✎: I show you the code changes I made when I did it.
- ⓘ: a helpful hint
One final thing to note is that there will be many points where you will need to fully restart your REPL for your changes to take effect. For example when changing deps.edn. I will not tell you every-time, you will need to recognise when that is necessary.
1. Basic components
1.1. Clojure CLI tools and Ring
Run these shell commands to get started:
mkdir -p polls/{src/clj/polls,test/clj/polls,resources} cd polls cat <<EOF > deps.edn {:paths [] :deps {} :aliases {}} EOF cat <<EOF > .gitignore /.cpcache/ /.nrepl-port /target/ /log /node_modules/ EOF
ⓘ to confirm Clojure is reading your deps.edn, you can run clj -Sdescribe
.
Now edit your deps.edn
:
- to
:paths
, add"src/clj"
and"resources"
- to
:deps
, add ring-core and ring-jetty-adapter, using the string from clojars.org
✎ changes
Now fire up the Clojure REPL in your editor/IDE. I use CIDER with Emacs.
When your REPL starts, it will download those dependencies and give you a Clojure shell in the user namespace.
Create a Clojure file src/clj/polls/core.clj
.
Add a namespace declaration at top, including an import for
ring.adapter.jetty
as jetty.
Then add a handler. Ring will call this for every HTTP request:
(defn handler [request] {:status 200 :headers {"Content-Type" "text/html"} :body "Hello World"})
Now, make a Rich Comment to start your webserver.
(comment (jetty/run-jetty handler {:port 3000 :join? false}) )
✎ changes
Using the appropriate keybindings for our IDE, evaluate your buffer, then evaluate your rich comment to start your webserver.
Now you can see your Hello World in your browser!
1.2. Database Connection
Now we setup a connection to Postgres.
So you have something to connect to, setup a postgres server, db and user like this.
Add seancorfield/next.jdbc and org.postgresql/postgresql to your deps.edn
.
Now restart your REPL to install the dependencies, and require
next.jdbc
as jdbc in your core namespace.
✎ changes
Now look at the docs for next.jdbc/get-datasource
to see what connection
options we have.
Utilising this knowledge you can connect and do a test. Use your rich comment or just put it directly in the REPL since we will re-write it in a sec:
(def my-datasource (jdbc/get-datasource {:dbtype "postgresql" :dbname "..." :user "..." :password "..."})) (def connection (jdbc/get-connection my-datasource)) (jdbc/execute! connection ["CREATE TABLE foo (bar TEXT)"]) (jdbc/execute! connection ["INSERT INTO foo (bar) VALUES (?);" "Hello World"]) (jdbc/execute! connection ["SELECT * FROM foo;"])
OK great it works. Now we know we can run an HTTP server and connect to a database.
1.2.1. 🏃 activity
This is your first activity. Write code to import your database credentials from an EDN file, instead of hard-coding them. Then you can move your datasource and connection out of your rich comment. Don't forget to put your EDN file in .gitignore
When you're finished, here's how I did it:
✎ changes
1.3. State and life-cycle management with Integrant
In order to make effective use of a functional language, and also have a good interactive development experience, we need a way for managing the life-cycle of stateful components, such as the server and connection objects we created earlier.
We will use Integrant for our state and life-cycle management.
If you want some historical context I recommend the following reading/watching list:
- This blog post from 2013 explains how to do interactive development with Clojure: Stuart Sierra's Reloaded Workflow
- Watch Stuart Sierra's video on component: Components Just Enough Structure
- Watch James Reeves video introducing Integrant: Enter Integrant
Now the thing to understand is, integrant is for managing stateful components. This is essentially:
- anything which runs continuously such as a webserver
- things which return a re-usable object like when you instantiate a database connection
- config which is read in from a file or the environment, which is needed for the other components to start
- things which do not meet the above criteria, but which depend on things which do. for example our handler. in this case Integrant's job is to closure over our handler so it has access to the config and database objects it needs
Alright let's start coding.
Add integrant to your deps.edn
. Require integrant.core as ig in your
core.clj
.
1.3.1. 🏃 activity
Your second activity is to implement Integrant for our project. To do this you will need to consume the pertinent learning materials:
Define methods for:
- reading our credentials file
- starting and stopping our database connection
- creating our handler
- starting and stopping our webserver
Define Integrant config, using references to indicate the dependencies between components.
(i) I know our handler doesn't actually use the database object yet, so your handler function strictly doesn't need to be created by Integrant. But of-course it will so just stub it out for now.
When you're finished, here's how I did it:
✎ changes
1.4. tools.build and Reloaded Workflow
1.4.1. Compiling your app with tools.build
So that we can compile our app into something deployable, we need a
-main
function which will use integrant to start our application. Then
we can use tools.build to compile it.
Update your code with these three functions to start your app, and handle the shutdown logic:
(defn system-config [] (ig/read-string (slurp "resources/system.edn"))) (defn halt-system [system] (ig/halt! system)) (defn -main [& args] (let [system (ig/init (system-config))] (.addShutdownHook (Runtime/getRuntime) (Thread. #(halt-system system)))))
In addition to having a -main
function, one more thing we need is to
add (:gen-class)
to your namespace declaration. Like this:
(ns polls.core (:require ...) (:gen-class))
Now you need to add an alias to your deps.edn. Do that following the guide here.
Now finally create a build.clj
in your top-level directory
(along-side your deps.edn) and paste in the example from the guide
uberjar example.
Fix that in the three places it refers to my/lib1
or my.lib.main
to just polls
and polls.core
.
Now you may build your app with this command:
clj -T:build uber
Your jar file is now created under target/
.
Shutdown your system in your REPL (to free up port 3000), then test you can run your standalone app:
java -jar target/polls-1.2.5-standalone.jar
✎ changes
1.4.2. Implementing your Reloaded Workflow with Integrant-repl
To support interactive development, we need some convenient REPL commands to allow reloading our code.
Remember, the "user" namespace is the default namespace your REPL uses. But at the moment it is an empty namespace because we haven't defined it.
Make a top-level directory env/dev/ containing:
- a user.clj
- another directory called resources/
Make our env directory:
mkdir -p env/dev/resources/ env/prod/resources/
Now, following the guidelines in the integrant-repl README, we can
create our user namespace at env/dev/user.clj
:
(ns user (:require [integrant.core :as ig] [integrant.repl :refer [clear go halt prep init reset reset-all]] [polls.core :as pc] [clojure.repl :refer [doc]])) (integrant.repl/set-prep! #(ig/prep (pc/system-config)))
As this namespace is only used for development, and has an extra
dependency, so we need an alias in our deps.edn
which loads it. Add
this alias to your deps.edn
inside :aliases
:
:dev {:extra-paths ["env/dev" "env/dev/resources"] :extra-deps {integrant/repl {:mvn/version "0.3.3"}}}
Now you need to ensure that when you start your REPL it will use the
dev profile. If you are using Emacs with CIDER you can do this by
creating a .dir-locals.el
:
((nil . ((cider-clojure-cli-aliases . "dev"))))
Once you have that working and you start up your REPL, you should have
these functions available in your REPL, in the default user
namespace:
(go) (halt) (reset)
Now whenever you change your code you can reload it with (reset)
.
Now we need to move some things around to setup our profiles properly.
Move the system.edn and credentials file you made earlier into
"env/dev/resources". And write a function that finds system.edn
in the
classpath (instead of hardcoded path).
Then update build.clj
, adding the the prod resources directory to
the src-dirs.
✎ changes
1.5. Adding a Test Runner
Start by making a namespace with an example test.
test/clj/polls/core_test.clj:
(ns polls.core-test (:require [clojure.test :refer :all] [polls.core :refer :all])) (deftest test-read-edn-file (is (= "John Doe" (:name (read-edn-file "env/test/resources/example.edn")))))
env/test/resources/example.edn:
{ :id 123 :name "John Doe" :email "johndoe@example.com" :tags [:employee :manager] :address {:street "123 Elm St." :city "Springfield" :zip 98765} }
Evaluate your test to confirm it works (test-read-edn-file)
. If it
works, output will be nil
.
Now we setup our test runner.
Add an alias for :test
to your deps.edn, like they specify in the
test-runner docs.
Now you can easily run your tests with:
clj -X:test
Also, you may want to run your tests from your editor. In that case
you may want to add your test paths to your :dev
alias also.
✎ changes
1.6. Setup logging
Now we will setup logging with log4j.
Add reload4j to your dependencies. And also add tools.logging.
Create env/dev/resources/log4j.properties
:
# Set the root logger level to DEBUG and its only appender to FILE log4j.rootLogger=DEBUG, FILE # Define the FILE appender log4j.appender.FILE=org.apache.log4j.RollingFileAppender # Set the name of the file where the log statements will be logged log4j.appender.FILE.File=log/logfile.log # Set the immediate flush to true, meaning the logging events will be written immediately to the file log4j.appender.FILE.ImmediateFlush=true # Set the threshold level for the FILE appender log4j.appender.FILE.Threshold=DEBUG # Define the layout for the FILE appender log4j.appender.FILE.layout=org.apache.log4j.PatternLayout log4j.appender.FILE.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n # Define the maximum size of a log file before rollover log4j.appender.FILE.MaxFileSize=10MB # Define the number of backup files to keep log4j.appender.FILE.MaxBackupIndex=5
Now, test it in your repl.
This shows which logging implementation it's using:
(require '[clojure.tools.logging.impl :as impl]) (impl/name (impl/find-factory))
Now try to log something:
(require '[clojure.tools.logging :as log]) (log/info "testing the logger")
You should get logs at log/logfile.log
in debug mode.
We haven't setup request logging yet but you will still get DEBUG priority messages. Once we add reitit to handle our requests we'll set up request logging.
✎ changes
2. Add specialised libraries
We will use migratus for creating and migrating our tables. And we will use HoneySQL for writing our queries.
Add those two to your deps.edn
.
2.1. Database Migrations
2.1.1. Initial setup
We want to setup migratus so that we can run the migrations either from the user namespace, or from the command-line.
In user.clj, require [migratus.core :as migratus]
.
Now in core.clj you need integrant to create a migratus config:
;; migratus (defmethod ig/init-key :migratus/config [_ {:keys [database]}] {:store :database :migration-dir "migrations/" :db {:connection database :managed-connection? true}})
ⓘ note the option :managed-connection? true
. This is needed
otherwise migratus will close our database connection after it runs.
And edit your system.edn
to start that up:
:migratus/config (:database #ig/ref :db.sql/connection)
Now restart everything and you should be able to do this in your REPL, in user namespace:
(migratus/migrate (:migratus/config integrant.repl.state/system))
It won't do anything because we don't have any migrations so expected output is nil.
ⓘ don't forget to run (go)
so your system is actually running.
🏃 activity: create 0-argument functions in your user namespace called migrate, rollback and create-migration which run those migratus commands.
✎ changes
2.1.2. Your first migration
Test your create-migration function to make our first migration file
(create-migration "question-and-choice")
.
Now, fill in your migrations:
In up:
create table question ( question_id serial not null primary key, question_text text, pub_date timestamp default current_timestamp ); --;; create table choice ( choice_id serial not null primary key, question_ref integer references question (question_id) on delete cascade, choice_text text, votes integer default 0 not null );
In down:
drop table choice; --;; drop table question;
Run the migration:
(migrate)
Response code of nil
indicates no errors. So you now have the
database tables to support your app.
2.1.3. Command-line
To allow our project to actually be deployed you will need to be able to also run the migration functions once your app is compiled (and you no-longer have access to your REPL).
🏃 activity: create an extra Integrant system config, which will only run the bits we need for migrations. i.e. read in the database credentials, connect to the db, and initialise the migratus config.
And edit our main function, so that it reads it's arguments, and starts different systems accordingly:
- with no args, it prints usage
- with "run-app", it starts up the main system as before
- with "migrate" or "rollback" it will run the more minimal system (db only) run the pertinent command, then shut-down the system.
Now compile your app and test you can run your migration on the command-line, something like this:
java -jar target/polls-1.2.5-standalone.jar migrate
When you're finished, here's how I did it:
✎ changes
2.2. Query builder library
We are using HoneySQL for building our queries.
HoneySQL will go in it's own namespace because it likes to shadow core clojure functions. So make a "queries" namespace. You will need to write your namespace form like this:
(ns polls.queries ;; exclude these core clojure functions (:refer-clojure :exclude [distinct filter for group-by into partition-by set update]) (:require [honey.sql :as sql] [honey.sql.helpers :refer :all] ;; shadows core functions [clojure.core :as c] ;; so we can still access core functions [next.jdbc :as jdbc]))
Now write a function which takes a db connection and a HoneySQL map, and formats and runs it, using the connection from the atom.
;; the core namespace will closure over this with the connection (defn execute-query "takes a connection and a hugsql map, formats and executes it" [conn q] (jdbc/execute! conn (sql/format q)))
In your core namespace, require [polls.queries :as queries]
. Then
add a method to closure over execute-query
with the connection:
;; query builder (defmethod ig/init-key :polls/queryfn [_ {:keys [database]}] #(queries/execute-query database %))
Then update your system.edn:
:polls/queryfn {:database #ig/ref :db.sql/connection}
Define your first couple of queries in your queries namespace:
(defn create-question [text] (-> (insert-into :question) (values [{:question_text text}]))) (defn get-questions [] (-> (select :*) (from :question)))
So that you can actually test these, you will need to make some
changes in your user
namespace. Require [polls.queries :as q]
.
Then:
(defn query [& args] (apply (:polls/queryfn integrant.repl.state/system) args))
Restart your Integrant system. Now you can test your queries work in your REPL:
(query (q/create-question "Would you love a monsterman?")) (query (q/create-question "What time is it?")) (query (q/get-questions))
✎ changes
2.3. Routing, Format Negotiation, input & output coercion
We will use Hiccup for generating HTML, Reitit for routing and Muuntaja for format negotiation.
Add those three to your deps.edn
.
2.3.1. Hiccup & Reitit MVP
Create your handlers namespace with some stub pages rendered by hiccup:
(ns polls.handlers (:require [polls.queries :as q] [hiccup2.core :as h])) (defn render-message "render a message on an html page" [msg] (str (h/html [:html [:head] [:body [:p msg]]]))) (defn home-handler "stub" [x] {:status 200 :headers {"Content-Type" "text/html"} :body (render-message "Question list here")}) (defn question-handler "stub" [x] {:status 200 :headers {"Content-Type" "text/html"} :body (render-message "View Question")}) (defn post-handler "stub" [x] {:status 200 :headers {"Content-Type" "text/html"} :body (render-message "Answer Question")}) (defn results-handler "stub" [x] {:status 200 :headers {"Content-Type" "text/html"} :body (render-message "See results")})
Now we make a "routes" namespace. Our handler will send requests here to be routed by Reitit.
We adapt the reitit ring example.
(ns polls.routes (:require [muuntaja.core :as m] [reitit.ring :as ring] [reitit.coercion.spec] [reitit.ring.coercion :as rrc] [reitit.ring.middleware.muuntaja :as muuntaja] [reitit.ring.middleware.parameters :as parameters] [polls.handlers :as hl])) (def app (ring/ring-handler (ring/router [["/" {:handler hl/home-handler}] ["/question" {:get {:handler hl/question-handler} :post {:handler hl/post-handler}}] ["/results" {:handler hl/results-handler}]] ;; router data affecting all routes {:data {:coercion reitit.coercion.spec/coercion :muuntaja m/instance :middleware [parameters/parameters-middleware rrc/coerce-request-middleware muuntaja/format-response-middleware rrc/coerce-response-middleware]}})))
Now in your core
namespace, update your :polls/handler to just
return the app function from your new routes namespace.
✎ changes
Check that works and you can access those three pages.
2.3.2. Request logging
Now we have reitit let's just quickly setup request logging with ring-logger.
Add it to your deps.edn
.
In your routes.clj
require [ring.logger :as logger]
.
Now add logger/wrap-with-logger
to the end of your :middleware
.
Finally edit your log4j.properties
file and replace DEBUG with
INFO. This is so the INFO messages ring.logger creates are not drowned
out by all the DEBUG spam.
✎ changes
2.3.3. 404 handler
One thing that's annoying is the favicon tracebacks in the request log and generally the lack of error handling if hitting a URL that's not setup.
So we make a 404 handler. The docs for how we do this is here.
In handlers.clj
, add:
(defn not-found-handler "display not found page" [] {:status 404 :headers {"Content-Type" "text/html"} :body (ph/render-message "Page Not Found")})
In routes.clj
, add this as the second argument to ring/ring-handler
:
(constantly (hl/not-found-handler))
✎ changes
2.3.4. Running queries from our handler
Let's setup our index page so it fetches the list of questions from the database and displays them.
In-order to do that we need to allow our handlers to run queries,
which means they need a function pre-loaded with the
:polls/queryfn
. So start by updating your system.edn
to pass in
your query builder function instead of database connection:
:polls/handler {:queryfn #ig/ref :polls/queryfn}
Now, so that all our handlers have access to this "queryfn", we configure reitit to inject it into the request object using a middleware.
Update your handler method in core.clj
:
(defmethod ig/init-key :polls/handler [_ {:keys [queryfn]}] (routes/app queryfn))
Now in routes.clj
, re-write your "app" function like this:
(defn app "reitit with format negotiation and input & output coercion" [queryfn] ;; we define a middleware that includes our query builder (let [wrap-query-builder (fn [handler] (fn [request] (handler (assoc request :queryfn queryfn))))] (ring/ring-handler (ring/router [["/" {:handler hl/home-handler}] ["/question" {:get {:handler hl/question-handler} :post {:handler hl/post-handler}}] ["/results" {:handler hl/results-handler}]] ;; router data affecting all routes {:data {:coercion reitit.coercion.spec/coercion :muuntaja m/instance :middleware [parameters/parameters-middleware rrc/coerce-request-middleware muuntaja/format-response-middleware rrc/coerce-response-middleware wrap-query-builder]}}))))
See how we define our middleware in a let, and add it to the middleware at the bottom.
✎ changes
2.3.5. Setting up the queries for the index page.
In handlers.clj
, split your render-message
so you have a
re-usable base template and individual templates for different pages:
(defn render-base "the basic structure of an html document. takes a title and list of elements" [title contents] (-> [:html [:head [:title title]] [:body [:header [:h1 title]] (into [:main] contents)]] h/html str)) (defn render-message "render a message on an html page" [msg] (let [contents [:p msg]] (render-base "Message" [contents]))) (defn render-questions "render a message on an html page" [questions] (let [contents (into [:ul] (for [{:question/keys [question_text question_id pub_date]} questions] [:li (str pub_date ": ") [:a {:href (str "/question/" question_id)} question_text]]))] (render-base "Questions List" [contents])))
Now you may setup your home-handler to get the query-builder function from the request object (where the middleware put it) and use it to run the queries:
(defn home-handler "display the list of questions" [{exec-query :queryfn}] {:status 200 :headers {"Content-Type" "text/html"} :body (render-questions (exec-query (q/get-questions)))})
Now you should be able to see your questions listed on the index page. I know the date needs formatting but you can fix that yourself if you wish.
✎ changes
2.3.6. Coercion and validation
Next is to setup the question page. As it relies on the URL path to know the question id so we need to configure reitit to validate and coerce.
Note that there are three coercion modules for reitit. We are using the spec one, with clojure.spec.
Add [clojure.spec.alpha :as s]
to your routes.clj
requires. Define
a spec for validating question ids:
(s/def ::id int?)
Now you need to update your path to extract the question_id
, and add
a :parameters key to specify the validation. The
parameters-middleware
will read and action the validation.
["/question/:question_id" {:get {:handler hl/question-handler :parameters {:path {:question_id ::id}}} :post {:handler hl/post-handler :parameters {:path {:question_id ::id}}}}]
Now in the handlers namespace, update your question-handler
to
print out the question specified by the id:
(defn question-handler "display a specific question" [{exec-query :queryfn {{:keys [question_id]} :path} :parameters}] {:status 200 :headers {"Content-Type" "text/html"} :body (ph/render-message (-> (exec-query (q/get-question question_id)) first :question/question_text))})
Now create your query to get a specific question from an id:
(defn get-question "We output a query and post-processor" [question_id] (-> (select :question_text) (from :question) (where [:= :question_id question_id])))
Check that works.
✎ changes
2.3.7. Enhancing our query namespace
Something that's a bit inelegant about what we just did is that we had to post-process our query to extract the part we want.
(-> (exec-query (q/get-question question_id)) first :question/question_text)
So let's enhance our execute-query function to take two values, a query and a post-processor function. Also we add an optional debug parameter so we can see what Honey SQL has generated.
(defn execute-query "takes a connection, a hugsql query, and a post-processor function" [conn [query processor] & [debug]] (let [formatted-query (sql/format query)] (when debug (println (str "formatted-query: " formatted-query))) (-> (jdbc/execute! conn formatted-query) processor)))
Update all the query functions to return a vector of two values:
- the query
- the
identity
function as a placeholder for the post-processor
✎ changes
🏃 activity: implement a post-processor for get-question
, so you
can remove the threading from the question-handler
.
When you're finished, here's how I did it:
✎ changes
2.3.8. Serving assets
It's highly likely you will want to serve static assets at some point (CSS, JavaScript, images). We can do that with ring.middleware.resource, ring.middleware.content-type and a reitit catch-all parameter.
Make a directory resources/public/style
.
Put in there a sample CSS file:
body{ margin:40px auto; max-width:650px; line-height:1.6; font-size:18px; color:#444; padding:0 10px } h1,h2,h3{ line-height:1.2 }
Now in routes.clj
, add to your requires:
[ring.middleware.resource :refer [wrap-resource]]
[ring.middleware.content-type :refer [wrap-content-type]]
Add a new route like this:
["/public/*path" {:get {:middleware [wrap-content-type [wrap-resource ""]] :handler hl/not-found-handler}}]
Now you should be able to load http://localhost:3000/public/style/style.css
And check it has an appropriate Content-Type header.
Now just update your render-base template to use our new CSS.
✎ changes
3. Implementing the app
Now, finally all the mechanics are in-place so we can focus on implementing the features.
3.1. 🏃 activity: Add queries to create and list choices
This activity will give you practice on using HoneySQL. Refer to the docs on HoneySQL Functional Helpers.
Create a query "create-choice" that creates a choice, setting it's
choice_text
and question_ref
(other fields are automatic).
Remember our question_ref
will point to the id of a question, but
your query just needs to set it to an integer and PostgreSQL will
enforce the constraint.
Create a query "get-choices" that gets all fields of all choices that pertain to a given question id.
Test your queries by making some choices for your questions. Don't forget you have a helper function in your user namespace to run the queries.
When you're finished, here's how I did it:
✎ changes
3.2. 🏃 activity: Make the question page show the choices for a question
This activity will help you practice using Hiccup.
In your handlers.clj
, update your question page to list the choices
for a question. Later we will turn it into a form, but for now just
use <ul>
and <li>
tags.
ⓘ you will need to make a new rendering function, rather than using render-message.
When you're finished, here's how I did it:
✎ changes
3.3. 🏃 activity: Implement the "results" page
This activity will give you more practice with HoneySQL and Hiccup, and also at Reitit.
Update the results page, such that:
- it takes a question_id in the URL path just like the question page
- it displays the results for the poll. the get-choices query you already have can get this information
And make a new query save-vote
that increments the vote count for a
question. For incrementing the Honey SQL Operators docs may help.
When you're finished, here's how I did it:
✎ changes
3.4. Make a page to vote on the polls
We want to make it possible to vote on the polls. We will:
- implement Post/Redirect/Get pattern (PRG)
- use anti-forgery tokens for protection against CSRF
These are the middlewares we will be using:
Add ring-anti-forgery to your deps.edn.
Now in routes.clj
, you can setup the anti-forgery. As-per the
README, you need to add two middlewares; wrap-anti-forgery and
wrap-session. However, there are a couple of things to bear in mind:
- because of how reitit works, a separate chain of middlewares is built for each route. Therefore, we need to define our session store outside the middleware map
- the ordering of reitit middleware is the opposite of the threading example in the README. So wrap-session will need to go first.
Here is how I did it:
✎ changes
Now we add a form to our questions page.
Update render-choices
:
(defn render-choices "render the question and the list of choices" [q-text choices f-token] (let [choice-list [:form {:method "POST"} [:input {:name "__anti-forgery-token" :type "hidden" :value f-token}] (into [:fieldset [:legend [:h2 q-text]]] (mapcat identity (for [{:choice/keys [choice_text choice_id]} choices] [[:input {:type "radio" :name "choice" :id (str "choice" choice_id) :value choice_id}] [:label {:for (str "choice" choice_id)} choice_text] [:br]]))) [:input {:type "submit" :value "Vote"}]]] (render-base "Question Detail" [choice-list])))
Update question-handler
:
(defn question-handler "display a specific question" [{exec-query :queryfn {{:keys [question_id]} :path} :parameters f-token :anti-forgery-token}] {:status 200 :headers {"Content-Type" "text/html"} :body (render-choices (exec-query (q/get-question question_id)) (exec-query (q/get-choices question_id)) f-token)})
Now we update the router to validate the POST data from the form. In
our :parameters
map, we are already using :path to validate the
question_id from the URL path. Now, as-per the table in the docs, we
add a :form
to validate the choice id:
:parameters {:path {:question_id ::id} :form {:choice ::id}}
Now we update the post-handler to handle the post. This info is
available to the handler in the request :parameters :form
.
(defn post-handler "the user votes on a poll" [{exec-query :queryfn {{:keys [question_id]} :path {:keys [choice]} :form} :parameters}] (exec-query (q/save-vote choice)) {:status 303 :headers {"Location" (str "/results/" question_id)} :body ""})
Now you should be able to vote on the polls!
✎ changes
4. User authentication
Now, let's protect our site so only people who have a username and password can vote or see results.
4.1. Make tables
First we make a new migration to add an extra table to our
database. In your user
namespace:
(create-migration "user-table")
In up:
create table users ( login text primary key, password text not null, created_at timestamp not null default now() );
In down:
drop table users;
Run the migration:
(migrate)
4.2. Make queries
So we can store a hashed password, add buddy hashers to your deps.edn. Require into your queries namespace as hashers.
Make queries to add a new user, and select a user based on username.
(defn create-user "hashes the password" [login password] [(-> (insert-into :users) (values [{:login login :password (hashers/derive password)}])) identity]) (defn authenticate-user "we return true or false indicating authentication success" [login password] [(-> (select [:*]) (from :users) (where [:= :login login])) (fn [[{hashed :users/password}]] (hashers/check password hashed))])
Now create a testuser, and try authenticating:
(query (q/create-user "testuser" "testpass")) (query (q/authenticate-user "testuser" "testpass")) (query (q/authenticate-user "testuser" "wrongpass"))
✎ changes
4.3. add login page
We add a new page which will receive a username and password, and if valid, add the user to the session.
Add your new render function to your hiccup namespace:
(defn render-login "prompt for login deets" [redirect-to f-token & [errormsg]] (let [post-to (str "/login?redirect=" redirect-to) errorprint (if errormsg [:p [:em errormsg]] "") login-form [:form {:method "POST" :action post-to} [:input {:name "__anti-forgery-token" :type "hidden" :value f-token}] [:fieldset [:legend "Please login"] [:label {:for "username"} "Username"] [:input#username {:type "text" :name "username"}] [:br] [:label {:for "password"} "Password"] [:input#password {:type "password" :name "password"}] [:br]] [:input {:type "submit" :value "Login"}]]] (render-base "Login" [errorprint login-form])))
Add your new handlers:
(defn login-handler "show the login prompt. the parameter variable holds the url the user was trying to access before being sent here" [{{redirect "redirect" :or {redirect "/"}} :query-params f-token :anti-forgery-token}] {:status 200 :headers {"Content-Type" "text/html"} :body (ph/render-login redirect f-token)}) (defn login-post-handler "check the credentials. if success, redirect to the redirect url. else, display the login page again" [{exec-query :queryfn {redirect "redirect" :or {redirect "/"}} :query-params session :session {{:keys [username password]} :form} :parameters f-token :anti-forgery-token}] (if (exec-query (q/authenticate-user username password)) ;; login success {:status 303 :headers {"Location" redirect} :body "" :session (assoc session :user username)} ;; login failure {:status 200 :headers {"Content-Type" "text/html"} :body (ph/render-login redirect f-token "Login failed")}))
Add your new route to your routes namespace:
["/login" {:get {:handler hl/login-handler} :post {:handler hl/login-post-handler :parameters {:form {:username ::string :password ::string}}}}]
Add add the new definition for spec, next to the other one:
(s/def ::string string?)
✎ changes
4.4. middleware
Now you can login, but have no reason to since the site works without.
So we create a new middleware wrap-auth
, which checks the user is
logged in, else redirects to the login page.
(defn wrap-auth [handler] (fn [{:keys [uri query-string session] :as request}] (if (contains? session :user) ;; user is logged in. proceed (handler request) ;; user not logged in. redirect (let [redirect-url (java.net.URLEncoder/encode (str uri "?" query-string) "UTF-8")] {:status 303 :headers {"Location" (str "/login?redirect=" redirect-url)} :body ""}))))
Now add that middleware specifically to the /question
and /results
routes. This can go either at the same level as the :handler
or at
the level of the whole route.
["/question/:question_id" {:middleware [wrap-auth] :get ... ["/results/:question_id" {:middleware [wrap-auth] :handler ....
✎ changes
4.5. add logout path
User may want to logout.
You do this with a handler that returns the :session
with the user
disassociated:
(defn logout-handler "log out and redirect to /" [{session :session}] {:status 303 :headers {"Location" "/"} :body "" :session (dissoc session :user)})
Then just make a handler.
✎ changes
Now the user can logout by loading /logout
.
5. Final comments
We have now implemented a simple web app, demonstrating the use of a database, handling form submissions, and login sessions.
More importantly we have built a solid base upon-which we could build a broad range of applications.
If you want to make a new project inspired by this guide, there is a "template" branch in my repository which is essentially the app we built above with all the app removed, leaving behind just the skeleton.
One big thing that's missing, is any client-side code (ClojureScript). But I will make a follow-up article detailing how to do that.