INDEX | HOME

From Scratch - Clojure web app from the ground up

Table of Contents

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:

I make certain assumptions about the reader:

Where you see:

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:

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:

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.

Copyright 2023 Joseph Graham (joseph@xylon.me.uk)