@@ -1,3 +1,6 @@
|
||||
target
|
||||
.cpcache
|
||||
.git
|
||||
node_modules
|
||||
.shadow-cljs
|
||||
resources/public/js
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
.cpcache
|
||||
target
|
||||
|
||||
node_modules
|
||||
.shadow-cljs
|
||||
# Compiled UI (script/ui-build)
|
||||
resources/public/js
|
||||
|
||||
+21
@@ -1,3 +1,23 @@
|
||||
# Stage 1: compile the re-frame UI (ui/) to JS. shadow-cljs needs Node and a JVM. It writes
|
||||
# to ../resources/public/js (see ui/shadow-cljs.edn), i.e. /build/resources/public/js here.
|
||||
# trixie, not bookworm: its default-jre is Java 21, which shadow-cljs's Closure Compiler
|
||||
# requires (bookworm's is 17 -> UnsupportedClassVersionError).
|
||||
FROM node:22-trixie-slim AS ui
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends default-jre-headless \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build/ui
|
||||
COPY ui/package.json ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
# Resolve the ClojureScript deps in their own layer so source edits don't re-download them.
|
||||
COPY ui/shadow-cljs.edn ./
|
||||
RUN npx shadow-cljs classpath > /dev/null
|
||||
COPY ui/src ./src
|
||||
RUN npx shadow-cljs release app
|
||||
|
||||
# Stage 2: the server.
|
||||
FROM clojure:openjdk-11-tools-deps
|
||||
|
||||
RUN sed -i \
|
||||
@@ -17,6 +37,7 @@ RUN clojure -P -X:${DEPS_ALIASES} \
|
||||
&& echo "[build] cached jars: $(find /root/.m2 -name '*.jar' | wc -l)"
|
||||
|
||||
COPY . .
|
||||
COPY --from=ui /build/resources/public/js resources/public/js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# tbd
|
||||
|
||||
A small Clojure HTTP API built on [Ring](https://github.com/ring-clojure/ring) (Jetty) and
|
||||
[reitit](https://github.com/metosin/reitit), with Swagger UI docs, unit tests, dockerized
|
||||
integration tests, and joint line coverage.
|
||||
[reitit](https://github.com/metosin/reitit), with a ClojureScript
|
||||
[re-frame](https://github.com/day8/re-frame) front end (`ui/`), Swagger UI docs, unit tests,
|
||||
dockerized integration tests, and joint line coverage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker, for the integration tests and the `script/docker-*` tasks (all CI needs)
|
||||
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 11+, and [Babashka](https://babashka.org/) for `script/coverage-report`
|
||||
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 11+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
script/ui-build # compile the UI (once, and after UI changes)
|
||||
script/server [port]
|
||||
```
|
||||
|
||||
@@ -23,7 +25,9 @@ script/server 8080
|
||||
PORT=8080 script/server
|
||||
```
|
||||
|
||||
This is a thin wrapper around `clojure -X:run`, which also honors `PORT`.
|
||||
This is a thin wrapper around `clojure -X:run`, which also honors `PORT`. Then open
|
||||
http://localhost:3000 for the UI. Without `script/ui-build` the server still starts, but `/`
|
||||
shows a placeholder.
|
||||
|
||||
Or build and run the container:
|
||||
|
||||
@@ -36,7 +40,7 @@ docker run --rm -p 3000:3000 tbd
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/` | Plain-text landing message |
|
||||
| `GET` | `/` | The UI: an HTML shell that loads the compiled ClojureScript from `/js/main.js` |
|
||||
| `GET` | `/api/ping` | Health check, returns `pong` |
|
||||
| `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) |
|
||||
| `GET` | `/api-docs` | Swagger UI |
|
||||
@@ -47,6 +51,23 @@ curl localhost:3000/api/ping # pong
|
||||
curl 'localhost:3000/api/add?x=2&y=3' # {:result 5}
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
`ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
||||
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
||||
It's a small calculator form that calls `GET /api/add` and shows the sum (or the server's
|
||||
validation error).
|
||||
|
||||
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
|
||||
directory at `/js`, and serves `resources/public/index.html`, the shell that mounts the app, at `/`.
|
||||
|
||||
| Task | What it does |
|
||||
|------|--------------|
|
||||
| `script/ui-build` | Optimized release build into `resources/public/js` |
|
||||
| `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page |
|
||||
|
||||
The Docker image builds the UI in its own stage, so `docker build` needs no local Node.
|
||||
|
||||
## Development
|
||||
|
||||
Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that
|
||||
@@ -88,9 +109,11 @@ src/tbd/
|
||||
core.clj Router, Ring app, and server startup
|
||||
<group>/routes.clj Each route group (health, math, ...) owns its route subtree
|
||||
<group>/core.clj Business logic for the group, with no HTTP concerns
|
||||
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
|
||||
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
||||
test/ Unit tests
|
||||
integration-test/ Dockerized integration tests and coverage harness
|
||||
script/ Bash entry points (test, coverage, repl, ...) and coverage helpers
|
||||
script/ Bash entry points (server, ui-build, test, coverage, repl, ...) and coverage helpers
|
||||
```
|
||||
|
||||
To add a new group, create `tbd.<group>.routes` with a `routes` fn that returns a reitit route
|
||||
|
||||
@@ -52,10 +52,18 @@ bb integration-test/integration_test.bb http://localhost:3000 --watch --interval
|
||||
| # | Test | Checks |
|
||||
|---|------|--------|
|
||||
| 1 | `GET /` returns 200 | HTTP status code |
|
||||
| 2 | `Content-Type` header | `text/plain` |
|
||||
| 3 | Response body | Contains expected Clojure/Ring content |
|
||||
| 4 | Not HTML | Confirms plain text response |
|
||||
| 2 | `Content-Type` header | `text/html` |
|
||||
| 3 | Response body | The HTML shell: mount point and `/js/main.js` script tag |
|
||||
| 4 | `GET /js/main.js` | 200, JavaScript, non-empty, contains the UI's `/api/add` call |
|
||||
| 5 | `HEAD /` works | HTTP 200 |
|
||||
| 6 | `GET /api/ping` | 200, body `pong` |
|
||||
| 7 | `GET /swagger.json` | 200, JSON, references `/api/ping` |
|
||||
| 8 | `GET /api-docs` | 200, `text/html` |
|
||||
| 9 | `GET /api/add?x=2&y=3` | 200, EDN `{:result 5}` |
|
||||
| 10 | `GET /api/add` with bad params | 400 for a missing or non-numeric param |
|
||||
|
||||
Test 4 needs the compiled UI, which the Docker image includes (`script/integration-test`
|
||||
builds it). Against a locally running server, run `script/ui-build` first.
|
||||
|
||||
## Line Coverage
|
||||
|
||||
|
||||
@@ -68,23 +68,22 @@
|
||||
(assert-eq "HTTP status" 200 (:status (get* base-url)))
|
||||
(println)
|
||||
|
||||
(println "Test 2: Content-Type is text/plain")
|
||||
(assert-contains "Content-Type" "text/plain" (get (:headers (get* base-url)) "content-type" ""))
|
||||
(println "Test 2: Content-Type is text/html")
|
||||
(assert-contains "Content-Type" "text/html" (get (:headers (get* base-url)) "content-type" ""))
|
||||
(println)
|
||||
|
||||
(println "Test 3: Root response points at the API docs")
|
||||
(println "Test 3: Root serves the HTML shell for the UI")
|
||||
(let [body (:body (get* base-url))]
|
||||
(assert-contains "Body mentions /api-docs" "/api-docs" body)
|
||||
(assert-not-empty "Body is non-empty" body)
|
||||
(println)
|
||||
(assert-contains "Body has the mount point" "<div id=\"app\"" body)
|
||||
(assert-contains "Body loads the compiled UI" "<script src=\"/js/main.js\"" body))
|
||||
(println)
|
||||
|
||||
(println "Test 4: Response is plain text, not HTML")
|
||||
(if (and (not (str/includes? body "<!DOCTYPE"))
|
||||
(not (str/includes? body "<html")))
|
||||
(do (println " ✓ Response is not HTML") (swap! pass inc))
|
||||
(do (println " ✗ Response appears to be HTML")
|
||||
(swap! fail inc)
|
||||
(swap! errors conj "Response was HTML, expected plain text"))))
|
||||
(println "Test 4: GET /js/main.js serves the compiled UI")
|
||||
(let [resp (get* (str base-url "/js/main.js"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "javascript" (get (:headers resp) "content-type" ""))
|
||||
(assert-not-empty "Body is non-empty" (:body resp))
|
||||
(assert-contains "Bundle calls the add API" "/api/add" (:body resp)))
|
||||
(println)
|
||||
|
||||
(println "Test 5: HEAD / returns 200")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>tbd</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- The re-frame app (ui/) mounts here and replaces this placeholder. -->
|
||||
<div id="app">Loading… (if this never goes away, the UI hasn't been built: run script/ui-build)</div>
|
||||
<script src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,4 +10,8 @@ if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -f resources/public/js/main.js ]]; then
|
||||
echo "note: the UI isn't built, so / will show a placeholder. Run script/ui-build first." >&2
|
||||
fi
|
||||
|
||||
PORT=$port exec clojure -X:run
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compile the re-frame UI (ui/) to resources/public/js, which the server serves at /js.
|
||||
# Needs Node and a JDK. Run this before script/server; restart isn't required afterwards.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../ui"
|
||||
|
||||
[[ -d node_modules ]] || npm ci
|
||||
exec npx shadow-cljs release app
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild the UI on every change (hot reload, also serves the shadow-cljs dashboard on :9630).
|
||||
# The output lands in resources/public/js, so run script/server alongside and refresh the page.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../ui"
|
||||
|
||||
[[ -d node_modules ]] || npm ci
|
||||
exec npx shadow-cljs watch app
|
||||
+10
-4
@@ -12,6 +12,7 @@
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja-middleware]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[muuntaja.core :as m]
|
||||
[ring.util.response :as response]
|
||||
[tbd.health.routes :as health]
|
||||
[tbd.math.routes :as math]))
|
||||
|
||||
@@ -34,6 +35,12 @@
|
||||
:body (pr-str {:error "invalid request parameters"
|
||||
:details (:humanized (reitit.coercion/encode-error (ex-data e)))})})
|
||||
|
||||
(defn- index-handler
|
||||
"The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js."
|
||||
[_req]
|
||||
(-> (response/resource-response "public/index.html")
|
||||
(response/content-type "text/html; charset=utf-8")))
|
||||
|
||||
(def ^:private exception-middleware
|
||||
(exception/create-exception-middleware
|
||||
(assoc exception/default-handlers
|
||||
@@ -48,10 +55,7 @@
|
||||
:swagger {:info {:title "tbd API" :version "1.0.0"}}
|
||||
:handler (swagger-handler)}}]
|
||||
["/" {:get {:no-doc true
|
||||
:handler (fn [_req]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/plain; charset=utf-8"}
|
||||
:body "tbd API — see /api-docs for documentation, /swagger.json for the spec"})}}]
|
||||
:handler index-handler}}]
|
||||
(health/routes)
|
||||
(math/routes)]
|
||||
{:data {:muuntaja m/instance
|
||||
@@ -66,6 +70,8 @@
|
||||
router
|
||||
(ring/routes
|
||||
(swagger-ui/create-swagger-ui-handler {:path "/api-docs"})
|
||||
;; Compiled UI (script/ui-build writes resources/public/js).
|
||||
(ring/create-resource-handler {:path "/js" :root "public/js"})
|
||||
(ring/create-default-handler)))
|
||||
wrap-head))
|
||||
|
||||
|
||||
+11
-3
@@ -12,13 +12,21 @@
|
||||
(handler {:request-method method :uri uri :headers {}}))
|
||||
|
||||
(deftest root-test
|
||||
(let [resp (request :get "/")]
|
||||
(let [resp (request :get "/")
|
||||
body (slurp (:body resp))]
|
||||
(is (= 200 (:status resp)))
|
||||
(is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"])))
|
||||
(is (re-find #"/api-docs" (:body resp))))
|
||||
(is (= "text/html; charset=utf-8" (get-in resp [:headers "Content-Type"])))
|
||||
(testing "serves the HTML shell that mounts the re-frame UI"
|
||||
(is (re-find #"<div id=\"app\"" body))
|
||||
(is (re-find #"<script src=\"/js/main\.js\"" body))))
|
||||
(testing "HEAD is served like GET, without a body"
|
||||
(is (= 200 (:status (request :head "/"))))))
|
||||
|
||||
(deftest js-assets-test
|
||||
;; main.js only exists once script/ui-build has run, so only the miss is asserted here;
|
||||
;; the integration suite (which runs against the built image) checks the hit.
|
||||
(is (= 404 (:status (request :get "/js/nope.js")))))
|
||||
|
||||
(deftest swagger-spec-test
|
||||
(let [resp (request :get "/swagger.json")]
|
||||
(is (= 200 (:status resp)))
|
||||
|
||||
Generated
+238
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"name": "tbd-ui",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tbd-ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shadow-cljs": "^3.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readline-sync": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz",
|
||||
"integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shadow-cljs": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-3.5.3.tgz",
|
||||
"integrity": "sha512-tDb/lTtHYTCP6VflT575u5oFqx242DgBu/pMqUF7ctA4VbA01X8pJE+9TAPY+D9pfQqWpqGrZLgmDWsmrabYVQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"process": "^0.11.10",
|
||||
"readline-sync": "^1.4.10",
|
||||
"shadow-cljs-jar": "1.3.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"bin": {
|
||||
"shadow-cljs": "cli/runner.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shadow-cljs-jar": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz",
|
||||
"integrity": "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "tbd-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "ClojureScript (re-frame) front end for the tbd server",
|
||||
"scripts": {
|
||||
"watch": "shadow-cljs watch app",
|
||||
"release": "shadow-cljs release app"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shadow-cljs": "^3.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
;; The UI compiles straight into the server's resources dir, so the server picks it up from
|
||||
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
|
||||
{:source-paths ["src"]
|
||||
|
||||
:dependencies
|
||||
[[reagent "2.0.1"]
|
||||
[re-frame "1.4.7"]]
|
||||
|
||||
:builds
|
||||
{:app {:target :browser
|
||||
:output-dir "../resources/public/js"
|
||||
:asset-path "/js"
|
||||
:modules {:main {:init-fn tbd.ui.core/init}}
|
||||
:devtools {:after-load tbd.ui.core/reload}}}}
|
||||
@@ -0,0 +1,21 @@
|
||||
(ns tbd.ui.core
|
||||
(:require [re-frame.core :as rf]
|
||||
[reagent.dom.client :as rdc]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.views :as views]))
|
||||
|
||||
(defonce root (atom nil))
|
||||
|
||||
(defn- render []
|
||||
(rdc/render @root [views/app]))
|
||||
|
||||
(defn reload
|
||||
"Called by shadow-cljs after each hot reload."
|
||||
[]
|
||||
(rf/clear-subscription-cache!)
|
||||
(render))
|
||||
|
||||
(defn init []
|
||||
(rf/dispatch-sync [::events/initialize])
|
||||
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
||||
(render))
|
||||
@@ -0,0 +1,9 @@
|
||||
(ns tbd.ui.db)
|
||||
|
||||
(def default-db
|
||||
{:calc {:x "" :y ""
|
||||
;; {:status :idle}
|
||||
;; {:status :loading}
|
||||
;; {:status :ok :x "2" :y "3" :sum 5}
|
||||
;; {:status :error :message "..."}
|
||||
:result {:status :idle}}})
|
||||
@@ -0,0 +1,53 @@
|
||||
(ns tbd.ui.events
|
||||
(:require [cljs.reader :as reader]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[tbd.ui.db :as db]))
|
||||
|
||||
;; Effect: GET `uri`, then dispatch `(conj on-response {:ok? bool :status int-or-nil :body string})`.
|
||||
;; Network failures come back as `{:ok? false :status nil :body <message>}`.
|
||||
(rf/reg-fx
|
||||
::fetch
|
||||
(fn [{:keys [uri on-response]}]
|
||||
(-> (js/fetch uri)
|
||||
(.then (fn [resp]
|
||||
(.then (.text resp)
|
||||
#(rf/dispatch (conj on-response {:ok? (.-ok resp)
|
||||
:status (.-status resp)
|
||||
:body %})))))
|
||||
(.catch #(rf/dispatch (conj on-response {:ok? false :status nil :body (.-message %)}))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::initialize
|
||||
(fn [_ _] db/default-db))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-operand
|
||||
(fn [db [_ k value]]
|
||||
(assoc-in db [:calc k] value)))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::add
|
||||
(fn [{:keys [db]} _]
|
||||
(let [{:keys [x y]} (:calc db)]
|
||||
{:db (assoc-in db [:calc :result] {:status :loading})
|
||||
::fetch {:uri (str "/api/add?x=" (js/encodeURIComponent x)
|
||||
"&y=" (js/encodeURIComponent y))
|
||||
:on-response [::add-response x y]}})))
|
||||
|
||||
(defn- error-message
|
||||
"The API answers bad input with EDN like {:error \"...\" :details {:x [...]}}."
|
||||
[{:keys [status body]}]
|
||||
(let [{:keys [error details]} (try (reader/read-string body) (catch :default _ nil))]
|
||||
(cond
|
||||
error (str error (when (seq details) (str " (" (str/join ", " (map name (keys details))) ")")))
|
||||
status (str "HTTP " status)
|
||||
:else body)))
|
||||
|
||||
(rf/reg-event-db
|
||||
::add-response
|
||||
(fn [db [_ x y {:keys [ok? body] :as resp}]]
|
||||
(assoc-in db [:calc :result]
|
||||
(if ok?
|
||||
{:status :ok :x x :y y :sum (:result (reader/read-string body))}
|
||||
{:status :error :message (error-message resp)}))))
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns tbd.ui.subs
|
||||
(:require [re-frame.core :as rf]))
|
||||
|
||||
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
|
||||
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
|
||||
@@ -0,0 +1,38 @@
|
||||
(ns tbd.ui.views
|
||||
(:require [re-frame.core :as rf]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.subs :as subs]))
|
||||
|
||||
(defn- operand-input [k label]
|
||||
[:label label " "
|
||||
[:input {:type "number" :step "any" :required true :name (name k)
|
||||
:style {:width "6rem"}
|
||||
:value @(rf/subscribe [::subs/operand k])
|
||||
:on-change #(rf/dispatch [::events/set-operand k (.. % -target -value)])}]])
|
||||
|
||||
(defn- result-line [{:keys [status x y sum message]}]
|
||||
(case status
|
||||
:idle nil
|
||||
:loading [:p "Adding…"]
|
||||
:ok [:p {:aria-live "polite"} x " + " y " = " [:strong sum]]
|
||||
:error [:p {:role "alert" :style {:color "crimson"}} message]))
|
||||
|
||||
(defn calculator
|
||||
"Sends GET /api/add?x=..&y=.. on submit and shows the sum the server returns."
|
||||
[]
|
||||
(let [result @(rf/subscribe [::subs/result])]
|
||||
[:form {:on-submit (fn [e]
|
||||
(.preventDefault e)
|
||||
(rf/dispatch [::events/add]))}
|
||||
[:div {:style {:display "flex" :gap "1rem" :align-items "center"}}
|
||||
[operand-input :x "x"]
|
||||
"+"
|
||||
[operand-input :y "y"]
|
||||
[:button {:type "submit" :disabled (= :loading (:status result))} "Add"]]
|
||||
[result-line result]]))
|
||||
|
||||
(defn app []
|
||||
[:main {:style {:font-family "system-ui, sans-serif" :max-width "40rem" :margin "2rem auto"}}
|
||||
[:h1 "tbd"]
|
||||
[calculator]
|
||||
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]]])
|
||||
Reference in New Issue
Block a user