tbd
A small Clojure HTTP API built on Ring (Jetty) and
reitit, with a ClojureScript
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 (tools.deps) with JDK 11+, Node 20+ (with a JDK) for the UI build, and Babashka for
script/coverage-report
Running
script/ui-build # compile the UI (once, and after UI changes)
script/server [port]
The server listens on port 3000 by default; pass a port argument or set PORT to change it
(the argument wins):
script/server 8080
PORT=8080 script/server
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:
docker build -t tbd .
docker run --rm -p 3000:3000 tbd
API
| Method | Path | Description |
|---|---|---|
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 |
GET |
/swagger.json |
OpenAPI/Swagger spec |
GET |
/auth/login |
Starts the Gitea OAuth login (redirects to Gitea) |
GET |
/auth/callback |
OAuth redirect target: completes the login, sets the session cookie, redirects to / |
POST |
/auth/logout |
Ends the session |
GET |
/auth/me |
The logged-in user, {:login "alice" :id 7 :admin? true} (401 if not logged in) |
GET |
/nrepl |
Admin only. Whether the nREPL debug server is on: {:enabled false}, or {:enabled true :bind "127.0.0.1" :port 7888} |
POST |
/nrepl |
Admin only. Body {"enabled": true} (JSON or EDN) starts the nREPL debug server, false stops it; returns {:enabled true :bind "127.0.0.1" :port 7888} |
curl localhost:3000/api/ping # pong
curl 'localhost:3000/api/add?x=2&y=3' # {:result 5}
Authentication and the admin API
Login is OAuth2 (authorization-code flow) against a Gitea instance.
Anyone with a Gitea account can log in, but routes marked :admin? true (currently
GET and POST /nrepl) also require the user to be on the ADMIN_USERS allowlist: 401 without a
login, 403 for a logged-in non-admin. With no allowlist, nobody is an admin.
- In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with
the redirect URI
<BASE_URL>/auth/callback. cp .env-template .envand fill it in.script/serverloads.env; for Docker usedocker run --env-file .env..envis git-ignored and kept out of the image.
| Variable | Purpose |
|---|---|
GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET |
The Gitea OAuth2 application. Without all three (and BASE_URL), /auth/login answers 503 |
BASE_URL |
Public URL of this app; an https:// one also marks the session cookie Secure |
ADMIN_USERS |
Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
SESSION_SECRET |
Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
NREPL_PORT, NREPL_BIND |
Where the debug nREPL listens (default 7888 on 127.0.0.1) |
Sessions live in an encrypted, signed, HttpOnly, SameSite=Lax cookie and expire after 8 hours
(enforced server-side). The cookie is stateless, so POST /auth/logout clears it in the browser but
can't revoke a copy someone already captured; it just expires on its own.
The nREPL endpoint
# after logging in in a browser, copy the `tbd-session` cookie value:
curl -XPOST localhost:3000/nrepl -H 'Cookie: tbd-session=...' \
-H 'Content-Type: application/json' -d '{"enabled": true}'
clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.2.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 7888
nREPL has no authentication and evaluates arbitrary code, so enabling it is effectively remote code
execution for whoever can reach the port. It therefore binds to loopback by default; reach it through
an SSH tunnel (or docker exec) rather than setting NREPL_BIND=0.0.0.0. Each enable/disable is
logged with the admin's Gitea username. It stays on until an admin disables it (or the server
restarts), so switch it off when you're done.
UI
ui/ is a re-frame app (ClojureScript, React 19 via
Reagent) built with 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
file doesn't exist:
script/repl
Tests
| Task | What it does |
|---|---|
script/unit-test |
Runs the Clojure tests in test/ |
script/integration-test |
Builds the app image, starts it on a random port, runs the integration suite against it, then tears it down |
script/test |
Unit tests, then integration tests (stops at the first failure) |
script/docker-unit-test |
Same as script/unit-test, but inside the app image (Docker only) |
Coverage
Line coverage via Cloverage:
| Task | Output |
|---|---|
script/unit-coverage |
target/coverage/unit/ |
script/integration-coverage |
target/coverage/integration/ |
script/coverage |
Runs both, then writes the merged report to target/coverage/merged/ |
script/docker-unit-coverage, script/docker-coverage-report |
Dockerized versions of the unit run and the merge step, so script/docker-unit-coverage, script/integration-coverage, script/docker-coverage-report is script/coverage with no local Clojure or Babashka (this is what CI runs) |
Each report directory has an index.html and an lcov.info. See
integration-test/README.md for how integration coverage is
collected.
Project layout
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
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
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 (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
vector, then add it to the router in src/tbd/core.clj. To make a route admin-only, put
:admin? true in its route data.