INDEX | HOME

Web Dev Crash Course - Heroku, Clojure, Luminus

Table of Contents

⚠ Consider this article superceded by this newer article

This article teaches you how to make a traditional web application with Clojure and Luminus.

First we build a minimal app which reads and writes to a database. Then we move onto hosting it on Heroku, and finally move onto some middleware and user authentication.

Where you see:

Note I am using Debian Bullseye as my development system. Also note I assume a working knowledge of Clojure in this article.

1. Quickstart; a minimal Luminus app

apt install clojure leiningen postgres

Template out a luminus project called "polls":

lein new luminus polls +immutant +postgres

Immutant is for the webserver and Postgres for the database.

cd polls

# keep empty directories
find . -depth -type d -empty -exec touch {}/.gitkeep \;

# stop calva stuff ending up in git
cat >> .gitignore <<EOF
.calva/
.clj-kondo/
.lsp/
EOF

git init
git add .
git commit -m "initial commit"

lein repl

The repl runs in the user namespace by default (see env/dev/clj/user.clj). This provides functions specifically to help with dev, such as start and stop.

(start)

🚩 If you see errors in your REPL it can be because you forgot to run (start). If you need to get back to your user namespace you run (in-ns 'user).

See http://localhost:3000/.

Note you will want an extra terminal as you don't want to kill your repl every time you run a bash command.

When you ran start it printed a warning database connection URL was not found. Fix it thus:

sudo -u postgres psql
create database polls_dev;
create user polls_dev with password 'EDITME';
grant all on database polls_dev to polls_dev;

Now update dev-config.edn. (stop) and (start) the app. You should no longer see the warning.

Now we are ready to start making our app. The first thing we will do is design our database tables.

Delete the example migrations in resources/migrations/.

rm resources/migrations/*

Make new migrations:

(create-migration "question-and-choice")

🛈 if it errors you may need to run (start) in your user namespace.

We now add SQL to the migrations to make (and unmake) our database tables.

In up:

create table question (
  id serial not null primary key,
  question_text varchar(100),
  pub_date timestamp default current_timestamp
);

--;;

create table choice (
  id serial not null primary key,
  question integer references question (id) on delete cascade,
  choice_text varchar(100),
  votes integer default 0 not null
);

In down:

drop table choice;
drop table question;

Run the migration:

(migrate)

🚩 If you run the (migrate) and see error "Too many update results were returned", you forgot to put --;; between the queries.

Let's define a couple of queries. We do this by making a HugSQL SQL file. Add the following to resources/sql/queries.sql (you can delete the example queries that are already there):

-- :name create-question! :! :n
-- :doc creates a new question
insert into question
(question_text)
values (:question_text)

-- :name get-questions :? :*
-- :doc retrieves all questions
select * from question

This file will be parsed to create functions by the code in src/clj/polls/db/core.clj.

To make your repl pull in the changes without restarting it:

(in-ns 'polls.db.core)
(conman/bind-connection *db* "sql/queries.sql")

🚩 remember to do this when you write more HugSQL queries in future

Now you can test out your new query functions:

(get-questions)
(create-question! {:question_text "Would you love a monsterman?"})
(create-question! {:question_text "What time is it?"})
(get-questions)

Awesome! Now let's start writing our app.

The code for the routes is in src/clj/polls/routes/home.clj.

The templates are in resources/html/. These use Selmer.

🏃 Update the code and templates so that / shows the list of questions.

changes

🏃 Add queries to create and list choices. Test them by adding some choices to your questions and listing them.

changes

🏃 Make an extra page to show the choices for a question, based on a question id in a query string. Make the questions on the homepage link to it.

changes

The next step is to allow to vote on the polls. This requires the use of a form and some extra queries and pages. This isn't easy to work out for yourself so for this bit I invite you to look at how I did it:

changes

Regarding detail.html; note the {% csrf-field %}. This adds a hidden form input containing an anti-forgery token which Luminus will validate.

Regarding home.clj; we are using Integer/parseInt because we need to convert the string we get from the HTTP query string into an integer to pass to postgres.

2. Hosting it; 12-factor app Heroku

We want our app to be 12-factor so we can easily host it in modern hosting environments. A good way to prove 12-factor is to host it on Heroku.

Luminus makes this really easy as we already have a Procfile and .gitignore. 🏃 Follow the guide here: https://luminusweb.com/docs/deployment.html#heroku_deployment

A few things to note from when I did this:

  • before I could run heroku local, I had to do the following:
    • add cheshire to the deps in project.clj
    • run lein uberjar
    • make a file .env containing: DATABASE_URL=jdbc:postgresql://localhost/polls_dev?user=polls_dev&password=EDITME
  • heroku run lein run migrate didn't work for me so I modified the start-app function.

changes

3. Adding users and authentication

3.1. tables, queries and functions

We will start by adding the database tables and queries to create and authenticate users.

(create-migration "user")

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 in the user namespace:

(migrate)

Then add code like this to add the authentication functions:

changes

After restarting to pull in the deps you may test it:

(in-ns 'polls.auth)
 (create-user! "testuser" "testpass")
(authenticate-user "testuser" "testpass")
(authenticate-user "testuser" "wrongpass")

3.2. login page

Add code thus:

changes

Note we made a seperate routes file because we will be applying a middleware to the main routes for authorization.

3.3. middleware

We can log in now but there's no reason to do so as the site works without login. So we need to add middleware to redirect to the login page if someone tries to access the polls without logging in.

Study the docs:

Now write your code thus:

changes

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