This tutorial will guide you through building a simple guestbook application using Macchiato. The guestbook allows users to leave a message and to view a list of messages left by others. The application will demonstrate the basics of HTML templating, database access, and project architecture.
You'll need the following prerequisites to get started:
Once you have Leiningen installed you can run the following commands in your terminal to initialize your application:
~ $ lein new macchiato guestbook
~ $ cd guestbook
The above will create a new template project.
~ $ lein build
This will start the Figwheel compiler for ClojureScript. Once the sources are compiler, open a new terminal window and run the project with Node.js as follows:
~ $ cd guestbook
~ $ node target/out/guestbook.js
Once the server starts, you can visit your site at localhost:3000
.
The newly created application has the following structure:
├── env
│ ├── dev
│ │ ├── guestbook
│ │ │ └── app.cljs
│ │ └── user.clj
│ ├── prod
│ │ └── guestbook
│ │ └── app.cljs
│ └── test
│ └── guestbook
│ └── app.cljs
├── public
│ └── css
│ └── site.css
├── src
│ └── guestbook
│ ├── config.cljs
│ ├── core.cljs
│ ├── middleware.cljs
│ └── routes.cljs
├── test
│ └── guestbook
│ └── core_test.cljs
├── Dockerfile
├── .dockerignore
├── .gitignore
├── LICENSE
├── Procfile
├── project.clj
├── README.md
└── system.properties
Let's take a look at what the files in the root folder of the application do:
Dockerfile
- used to containerize the application.dockerignore
- a list of assets, such as build generated files, to exclude from Docker.gitignore
- a list of assets, such as build generated files, to exclude from GitLICENSE
- a license file for the projectProcfile
- used to facilitate Heroku deploymentsproject.clj
- used to manage the project configuration and dependencies by LeiningenREADME.md
- where documentation for the application is conventionally putsystem.properties
- a collection of system metadataThis directory contains any public assets for the applications that will be served by the HTTP server.
All our code lives under the src
folder. Since our application is called guestbook, this is the root namespace for the project. Let's take a look at all the namespaces that have been created for us.
core.clj
- this is the entry point for the application that contains the logic for starting and stopping the serverconfig.clj
- defines the configuration for the application based on the environment variablesroutes.clj
- defines the HTTP routes for the applicationmiddleware.clj
- a namespace that contains the common middleware for the HTTP routesEnvironment specific code and resources are located under the env/dev
, env/test
, and the env/prod
paths. The dev
configuration will be used during development, test
during testing, while the prod
configuration will be used when the application is packaged for production.
The user
namespace in the env/dev
folder is used for starting the ClojureScript REPL.
The app.cljs
file is the entry point for the application. The dev version of this file looks as follows:
(ns ^:figwheel-always guestbook.app
(:require
[guestbook.core :as core]
[cljs.nodejs :as node]
[mount.core :as mount]))
(mount/in-cljc-mode)
(cljs.nodejs/enable-util-print!)
(.on js/process "uncaughtException" #(js/console.error %))
(set! *main-cli-fn* core/server)
You can see that this namespace sets environment properties such as the Mount configuration, printing, and error handling. Finally, it sets the core/app
function as the main function to be run by Node.js.
The production version of this namespace looks a little different:
(ns guestbook.app
(:require
[guestbook.core :as core]
[cljs.nodejs]
[mount.core :as mount]))
(mount/in-cljc-mode)
(cljs.nodejs/enable-util-print!)
(set! *main-cli-fn* core/server)
We're no longer using Figwheel, and we don't have a global exception handler here.
Here is where we put tests for our application, a couple of sample tests have already been defined for us.
As was noted above, all the dependencies are managed via updating the project.clj
file. The project file of the application we've created is found in its root folder and should look as follows:
(defproject guestbook "0.1.0-SNAPSHOT"
:description "FIXME: write this!"
:url "http://example.com/FIXME"
:dependencies [[bidi "2.1.2"]
[com.cemerick/piggieback "0.2.2"]
[com.taoensso/timbre "4.10.0"]
[macchiato/hiccups "0.4.1"]
[macchiato/core "0.2.7"]
[macchiato/env "0.0.6"]
[mount "0.1.11"]
[org.clojure/clojure "1.9.0"]
[org.clojure/clojurescript "1.9.946"]
;; needed for JDK 9 compatibility
[javax.xml.bind/jaxb-api "2.3.0"]]
:min-lein-version "2.0.0"
:jvm-opts ^:replace ["-Xmx1g" "-server"]
:plugins [[lein-doo "0.1.7"]
[macchiato/lein-npm "0.6.4"]
[lein-figwheel "0.5.14"]
[lein-cljsbuild "1.1.5"]]
:npm {:dependencies [[source-map-support "0.4.6"]]
:write-package-json true}
:source-paths ["src" "target/classes"]
:clean-targets ["target"]
:target-path "target"
:profiles
{:dev
{:npm {:package {:main "target/out/guestbook.js"
:scripts {:start "node target/out/guestbook.js"}}}
:dependencies [[figwheel-sidecar "0.5.14"]]
:cljsbuild
{:builds {:dev
{:source-paths ["env/dev" "src"]
:figwheel true
:compiler {:main guestbook.app
:output-to "target/out/guestbook.js"
:output-dir "target/out"
:target :nodejs
:optimizations :none
:pretty-print true
:source-map true
:source-map-timestamp false}}}}
:figwheel
{:http-server-root "public"
:nrepl-port 7000
:reload-clj-files {:clj false :cljc true}
:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}
:source-paths ["env/dev"]
:repl-options {:init-ns user}}
:test
{:cljsbuild
{:builds
{:test
{:source-paths ["env/test" "src" "test"]
:compiler {:main guestbook.app
:output-to "target/test/guestbook.js"
:target :nodejs
:optimizations :none
:pretty-print true
:source-map true}}}}
:doo {:build "test"}}
:release
{:npm {:package {:main "target/release/guestbook.js"
:scripts {:start "node target/release/guestbook.js"}}}
:cljsbuild
{:builds
{:release
{:source-paths ["env/prod" "src"]
:compiler {:main guestbook.app
:output-to "target/release/guestbook.js"
:language-in :ecmascript5
:target :nodejs
:optimizations :simple
:pretty-print false}}}}}}
:aliases
{"build" ["do"
["clean"]
["npm" "install"]
["figwheel" "dev"]]
"package" ["do"
["clean"]
["npm" "install"]
["with-profile" "release" "npm" "init" "-y"]
["with-profile" "release" "cljsbuild" "once"]]
"test" ["do"
["npm" "install"]
["with-profile" "test" "doo" "node"]]})
As you can see the project.clj
file is simply a Clojure list containing key/value pairs describing different aspects of the application.
The most common task is adding new libraries to the project. These libraries are specified using the :dependencies
vector. In order to use a new library in our project we simply have to add its dependency here.
NPM dependencies are managed under the :npm
key, this is where you can add modules from npmjs.com.
The items in the :plugins
vector can be used to provide additional functionality such as reading environment variables via lein-cprop
plugin.
The :profiles
contain a map of different project configurations that are used to initialize it for either development or production builds.
The :cljsbuild
key under the profiles controls the compiler options for ClojureScript.
The :aliases
key points to composite tasks needed for building, testing, and packaging the application for production.
As you can see the project structure is relatively straightforward:
env
folder contains profile specific source codesrc
folder contains the application source codetest
folder contains test code and test assetspublic
folder contains any static assets for the applicationproject.clj
file is used to manage the build configurationWe'll start by adding a SQLite database to the application. Let's open the project.clj
file and add the following NPM dependencies:
:npm {:dependencies [[source-map-support "0.4.6"]
[sqlite3 "3.1.8"] ;; <-- SQLite NPM module
]}
If you have lein build
running, then you'll need to restart it in order to load the new dependency.
Next, let's add a new namespace file called src/guestbook/db.cljs
. We'll start by adding the following namespace declaration:
(ns guestbook.db
(:require
[cljs.nodejs :as node]
[mount.core :refer [defstate]]))
With that in place, we can define a var for the sqlite3 library:
(def sqlite3 (node/require "sqlite3"))
We can now use Mount defstate
to create the database resource:
(defstate db
:start (let [db (sqlite3.Database. ":memory:")]
(.run
db
"CREATE TABLE guestbook
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(30),
message VARCHAR(200),
time TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"))
:stop (.close @db))
The state will initialize an in-memory database when it starts, and close it when it stops. Let's add the functions to add messages to the guestbook and read the saved messages from it:
(defn add-message [{:keys [name message]}]
(.run @db "INSERT INTO guestbook (name, message) VALUES (?, ?)" #js [name message]))
(defn messages [handler]
(.all @db "SELECT * FROM guestbook" handler))
Let's navigate to the guestbook.routes
namespace to add the code for displaying the message form and saving the messages entered by the users. First, we'll need to update the namespace declaration as follows:
(ns guestbook.routes
(:require
[bidi.bidi :as bidi]
[hiccups.runtime]
[guestbook.db :as db]
[macchiato.middleware.anti-forgery :as af]
[macchiato.util.response :as r]
[macchiato.middleware.restful-format :as restful-format]
[cljs.nodejs :as node])
(:require-macros
[hiccups.core :refer [html]]))
We've added a reference to the db
namespace we created earlier, and the anit-forgery
namespace. We'll need the latter to create a CSRF anti-forgery token in our form.
We can now update the home
route as follows:
(defn home [req res raise]
(let [af-token af/*anti-forgery-token*]
(db/messages
(fn [err result]
(if err
(raise err)
(->
[:html
[:body
[:h2 "Messages"]
[:ul
(for [{:keys [name message time]} (js->clj result :keywordize-keys true)]
[:li name " says " message " at " time])]
[:hr]
[:h2 "leave a message"]
[:form {:method "POST" :action "/message"}
[:input
{:type :text
:name "name"
:placeholder "name"}]
[:input
{:type "hidden"
:name "__anti-forgery-token"
:value af-token}]
[:input
{:type :text
:name "message"
:placeholder "message"}]
[:input
{:type :submit
:value "add message"}]]]]
(html)
(r/ok)
(r/content-type "text/html")
(res)))))))
The rest of the code in the function generates the HTML content for the page. We call db/messages
to retrieve the currently stored messages and display them. Then we add a form that will allow the users to post a new message to the /message
route.
Next, we'll add the handler for adding new messages that will look as follows:
(defn message [req res raise]
(db/add-message (select-keys (:params req) [:name :message]))
(res (r/found "/")))
All we do here is call the db/add-message
function with the form parameters and redirect the user back to the home page.
Finally, we'll have to add the new route to the routes
definition as follows:
(def routes
["/" {"" {:get home}
"message" {:post message}}])
That's all there is to it.
To package our application for production, we have to run the following command in the terminal:
lein package
This will compile the application and produce the target/release/guestbook.js
file. It will also print the following configuration to the terminal:
{
"private": true,
"name": "guestbook",
"description": "FIXME: write this!",
"version": "0.1.0-SNAPSHOT",
"dependencies": {
"random-bytes": "1.0.0",
"multiparty": "4.1.3",
"source-map-support": "0.4.6",
"ws": "3.3.3",
"sqlite3": "3.1.8",
"cookies": "0.7.1",
"etag": "1.8.1",
"lru": "3.1.0",
"qs": "6.5.1",
"content-type": "1.0.4",
"url": "0.11.0",
"simple-encryptor": "1.1.1",
"concat-stream": "1.6.0"
},
"main": "target/release/guestbook.js",
"scripts": {
"start": "node target/release/guestbook.js"
},
"directories": {
"test": "test"
},
"devDependencies": {},
"keywords": [],
"author": "",
"license": "ISC"
}
You'll have to save this configuration in a file called package.json
and publish it along with your application.