@@ -559,7 +559,7 @@ modules: logging and web.
559
559
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
560
560
org.duct-framework/main {:mvn/version "0.1.5"}
561
561
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 "}}
563
563
:aliases {:duct {:main-opts ["-m" "duct.main"]}}}
564
564
----
565
565
@@ -800,7 +800,7 @@ Our project dependencies should now look like this:
800
800
{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
801
801
org.duct-framework/main {:mvn/version "0.1.5"}
802
802
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 "}
804
804
org.duct-framework/module.sql {:mvn/version "0.7.1"}
805
805
org.xerial/sqlite-jdbc {:mvn/version "3.47.0.0"}
806
806
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
1124
1124
org.duct-framework/main {:mvn/version "0.1.5"}
1125
1125
org.duct-framework/module.cljs {:mvn/version "0.5.0"}
1126
1126
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 "}
1128
1128
org.duct-framework/module.sql {:mvn/version "0.7.1"}
1129
1129
org.xerial/sqlite-jdbc {:mvn/version "3.47.0.0"}
1130
1130
com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"}}
@@ -1191,3 +1191,221 @@ our `index` function in the `todo.routes` namespace.
1191
1191
1192
1192
If you restart the REPL and check http://localhost:3000, you should see
1193
1193
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