Skip to content

Commit ee730a1

Browse files
committed
Add 'Single Page Apps' section
1 parent 403bf5e commit ee730a1

File tree

1 file changed

+221
-3
lines changed

1 file changed

+221
-3
lines changed

Diff for: index.adoc

+221-3
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ modules: logging and web.
559559
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
560560
org.duct-framework/main {:mvn/version "0.1.5"}
561561
org.duct-framework/module.logging {:mvn/version "0.6.5"}
562-
org.duct-framework/module.web {:mvn/version "0.12.0"}}
562+
org.duct-framework/module.web {:mvn/version "0.12.6"}}
563563
:aliases {:duct {:main-opts ["-m" "duct.main"]}}}
564564
----
565565

@@ -800,7 +800,7 @@ Our project dependencies should now look like this:
800800
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
801801
org.duct-framework/main {:mvn/version "0.1.5"}
802802
org.duct-framework/module.logging {:mvn/version "0.6.5"}
803-
org.duct-framework/module.web {:mvn/version "0.12.0"}
803+
org.duct-framework/module.web {:mvn/version "0.12.6"}
804804
org.duct-framework/module.sql {:mvn/version "0.7.1"}
805805
org.xerial/sqlite-jdbc {:mvn/version "3.47.0.0"}
806806
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}}
@@ -1124,7 +1124,7 @@ ClojureScript. As always we begin with our dependencies, and add the
11241124
org.duct-framework/main {:mvn/version "0.1.5"}
11251125
org.duct-framework/module.cljs {:mvn/version "0.5.0"}
11261126
org.duct-framework/module.logging {:mvn/version "0.6.5"}
1127-
org.duct-framework/module.web {:mvn/version "0.12.0"}
1127+
org.duct-framework/module.web {:mvn/version "0.12.6"}
11281128
org.duct-framework/module.sql {:mvn/version "0.7.1"}
11291129
org.xerial/sqlite-jdbc {:mvn/version "3.47.0.0"}
11301130
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}}
@@ -1191,3 +1191,221 @@ our `index` function in the `todo.routes` namespace.
11911191

11921192
If you restart the REPL and check http://localhost:3000, you should see
11931193
the alert.
1194+
1195+
=== Single Page Apps
1196+
1197+
At this point we have all the tools we need to write a web application.
1198+
We can write routes that return HTML, and we write ClojureScript to
1199+
augment those roots.
1200+
1201+
However, there is a common alternative to this '`traditional`'
1202+
architecture. We instead serve up a single, static HTML page, and create
1203+
the UI dynamically with ClojureScript. Communication to the server will
1204+
be handled by a RESTful API.
1205+
1206+
In order to demonstrate this type of web application, we'll pivot and
1207+
redesign what we have so far. First, we require a static index file. By
1208+
default this should be placed in the `static` subdirectory.
1209+
1210+
.static/index.html
1211+
[,html]
1212+
----
1213+
<!DOCTYPE html>
1214+
<html>
1215+
<head>
1216+
<title>Todo</title>
1217+
</head>
1218+
<body>
1219+
<div id="todos"></div>
1220+
<script src="/cljs/client.cljs"></script>
1221+
</body>
1222+
</html>
1223+
----
1224+
1225+
We then need to change the routes and add the `:api` feature to the web
1226+
module.
1227+
1228+
.duct.edn
1229+
[,clojure]
1230+
----
1231+
{:vars {jdbc-url {:default "jdbc:sqlite:todo.db"}}
1232+
:system
1233+
{:duct.module/logging {}
1234+
:duct.module/sql {}
1235+
:duct.module/cljs {:builds {:client todo.client}}
1236+
:duct.module/web
1237+
{:features #{:site :api}
1238+
:handler-opts {:db #ig/ref :duct.database/sql}
1239+
:routes [["/todos"
1240+
{:get :todo.routes/list-todos
1241+
:post {:parameters {:body {:description :string}}
1242+
:handler :todo.routes/create-todo}}]
1243+
["/todos/:id"
1244+
{:parameters {:path {:id :int}}
1245+
:delete :todo.routes/remove-todo}]]}}}
1246+
----
1247+
1248+
There are now have three RESTful API routes:
1249+
1250+
- `GET /todos`
1251+
- `POST /todos`
1252+
- `DELETE /todos/:id`
1253+
1254+
By default, these will expect either JSON or edn, depending on the
1255+
type of the `Content-Type` and `Accept` headers.
1256+
1257+
The next step is to rewrite the handler functions for these routes.
1258+
Instead of returning HTML, we'll return data that will be translated
1259+
into the user's preferred format.
1260+
1261+
.src/todo/routes.clj
1262+
[,clojure]
1263+
----
1264+
(ns todo.routes
1265+
(:require [next.jdbc :as jdbc]))
1266+
1267+
(def select-all-todos "SELECT * FROM todo")
1268+
(def insert-todo "INSERT INTO todo (description) VALUES (?)")
1269+
(def delete-todo "DELETE FROM todo WHERE id = ?")
1270+
1271+
(defn list-todos [{:keys [db]}]
1272+
(fn [_request]
1273+
{:body {:results (jdbc/execute! db [select-all-todos])}}))
1274+
1275+
(defn create-todo [{:keys [db]}]
1276+
(fn [{{{:keys [description]} :body} :parameters}]
1277+
(let [id (val (first (jdbc/execute-one! db [insert-todo description]
1278+
{:return-keys true})))]
1279+
{:status 201, :headers {"Location" (str "/todos/" id)}})))
1280+
1281+
(defn remove-todo [{:keys [db]}]
1282+
(fn [{{{:keys [id]} :path} :parameters}]
1283+
(let [result (jdbc/execute-one! db [delete-todo id])]
1284+
(if (pos? (::jdbc/update-count result))
1285+
{:status 204}
1286+
{:status 404, :body {:error :not-found}}))))
1287+
----
1288+
1289+
There are three functions for each of the three routes. The `list-todos`
1290+
function returns a map as its body. If JSON is requested, the resulting
1291+
response body will look like something like this:
1292+
1293+
[,json]
1294+
----
1295+
{
1296+
"results": [
1297+
{
1298+
"todo/checked": 0,
1299+
"todo/description": "Test One",
1300+
"todo/id": 1
1301+
},
1302+
{
1303+
"todo/checked": 0,
1304+
"todo/description": "Test Two",
1305+
"todo/id": 2
1306+
}
1307+
]
1308+
}
1309+
----
1310+
1311+
The `create-todo` function creates a new todo item given a description,
1312+
and the `remove-todo` function deletes a todo item. In a full RESTful
1313+
application we'd have more verbs per route, but as this is just an
1314+
example we'll limit the application to the bare minimum.
1315+
1316+
The next step is to create the client code. For this we'll use
1317+
https://github.com/cjohansen/replicant[Replicant] for updating the DOM,
1318+
and https://github.com/r0man/cljs-http[cljs-http] for communicating with
1319+
the server API.
1320+
1321+
This requires us to once again update the project dependencies:
1322+
1323+
.deps.edn
1324+
[,clojure]
1325+
----
1326+
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
1327+
org.duct-framework/main {:mvn/version "0.1.5"}
1328+
org.duct-framework/module.cljs {:mvn/version "0.5.0"}
1329+
org.duct-framework/module.logging {:mvn/version "0.6.5"}
1330+
org.duct-framework/module.web {:mvn/version "0.12.6"}
1331+
org.duct-framework/module.sql {:mvn/version "0.8.0"}
1332+
org.xerial/sqlite-jdbc {:mvn/version "3.47.0.0"}
1333+
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}
1334+
no.cjohansen/replicant {:mvn/version "2025.03.02"}
1335+
cljs-http/cljs-http {:mvn/version "0.1.48"}}
1336+
:aliases {:duct {:main-opts ["-m" "duct.main"]}}}
1337+
----
1338+
1339+
Once we've run `sync-deps` in the REPL, we can create a ClojureScript
1340+
file for the client UI.
1341+
1342+
.src/todo/client.cljs
1343+
[,clojure]
1344+
----
1345+
(ns todo.client
1346+
(:require [replicant.dom :as r]
1347+
[cljs-http.client :as http]
1348+
[clojure.core.async :as a :refer [<!]]))
1349+
1350+
;; Helper functions that add anti-forgery headers.
1351+
(defn delete [url]
1352+
(http/delete url {:headers {"X-Ring-Anti-Forgery" "1"}}))
1353+
1354+
(defn post [url params]
1355+
(http/post url {:headers {"X-Ring-Anti-Forgery" "1"}, :json-params params}))
1356+
1357+
(defonce todos
1358+
(js/document.getElementById "todos"))
1359+
1360+
(defonce state (atom {}))
1361+
1362+
(defn update-todos []
1363+
(a/go (let [resp (<! (http/get "/todos"))]
1364+
(swap! state assoc :todos (-> resp :body :results)))))
1365+
1366+
(defn delete-todo [id]
1367+
(a/go (<! (delete (str "/todos/" id)))
1368+
(<! (update-todos))))
1369+
1370+
(defn create-todo []
1371+
(a/go (let [input (js/document.getElementById "todo-desc")]
1372+
(<! (post "/todos" {:description (.-value input)}))
1373+
(<! (update-todos))
1374+
(set! (.-value input) ""))))
1375+
1376+
(defn- create-todo-form []
1377+
[:div.create-todo
1378+
[:input#todo-desc {:type "text"}]
1379+
[:button {:on {:click create-todo}} "Create"]])
1380+
1381+
(defn todo-list [{:keys [todos]}]
1382+
[:ul
1383+
(for [{:todo/keys [id description]} todos]
1384+
[:li {:replicant/key id}
1385+
[:span description] " "
1386+
[:a {:href "#" :on {:click #(delete-todo id)}} "delete"]])
1387+
[:li (create-todo-form)]])
1388+
1389+
(add-watch state ::render (fn [_ _ _ s] (r/render todos (todo-list s))))
1390+
(update-todos)
1391+
----
1392+
1393+
Here we reach the edge of Duct. This ClojureScript file is not specific
1394+
to our framework, but would be at home in any Clojure project.
1395+
Nevertheless, for the sake of completeness we'll provide some
1396+
explanation of what this file does.
1397+
1398+
The `delete` and `post` functions add the `X-Ring-Anti-Forgery` header,
1399+
which is needed to get past the anti-forgery protection.
1400+
1401+
The `update-todos`, `delete-todo` and `create-todo` functions all update
1402+
the `state` atom, which contains a data structure that represents the
1403+
state of the UI. In this case, it's a list of todo items.
1404+
1405+
There is a watch attached to the `state` atom. When the state is
1406+
changed, the `todos` DOM element is updated accordingly, with a new
1407+
unordered list of todo items. Replicant is smart enough to update only
1408+
the elements that have changed, making updates efficient.
1409+
1410+
Now that we have both a server and client, we can `(reset)` the REPL
1411+
and check the web application at: <http://localhost:8080>

0 commit comments

Comments
 (0)