# tbd A small Clojure HTTP API built on [Ring](https://github.com/ring-clojure/ring) (Jetty) and [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+, [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] ``` The server listens on port `3000` by default; pass a port argument or set `PORT` to change it (the argument wins): ```bash 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: ```bash 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` | `/api/todos` | **Login required.** Your todos: `{:todos [{:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}` | | `POST` | `/api/todos` | **Login required.** Body `{"title": "buy milk"}` creates a todo and returns it with `201` (400 if the title is blank or over 200 characters) | | `GET` | `/api/todos/:id` | **Login required.** One todo (404 if it doesn't exist or isn't yours) | | `PATCH` | `/api/todos/:id` | **Login required.** Body with `title` and/or `done` (`{"done": true}`) updates the todo and returns it (400 for an empty or invalid change, 404 as above) | | `DELETE` | `/api/todos/:id` | **Login required.** Deletes the todo: `{:deleted true}` (404 as above) | | `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}` | ```bash curl localhost:3000/api/ping # pong curl 'localhost:3000/api/add?x=2&y=3' # {:result 5} ``` ## Todos Each logged-in user has their own todo list, keyed by their Gitea user id. Every query is filtered by that id, so users can't see or change each other's todos; someone else's todo answers `404`, exactly like a missing one. Without a login the todo routes answer `401`. Todos are stored in a local [SQLite](https://www.sqlite.org/) file, `data/todo.db` by default (git-ignored), which is created and migrated on the first todo request. Set `TODO_DB_PATH` to put it elsewhere. In Docker, mount a volume at that path (e.g. `docker run -v tbd-data:/app/data ...`), or the todos are lost with the container. ```bash # after logging in in a browser, copy the `tbd-session` cookie value: curl -XPOST localhost:3000/api/todos -H 'Cookie: tbd-session=...' \ -H 'Content-Type: application/json' -d '{"title": "buy milk"}' curl localhost:3000/api/todos -H 'Cookie: tbd-session=...' ``` ## Authentication and the admin API Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) 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. 1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with the redirect URI `/auth/callback`. 2. `cp .env-template .env` and fill it in. `script/server` loads `.env`; for Docker use `docker run --env-file .env`. `.env` is 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 | | `TODO_DB_PATH` | SQLite file for the todo group (default `data/todo.db`) | | `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 ```bash # 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](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 file doesn't exist: ```bash 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](integration-test/README.md) 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](https://github.com/cloverage/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](integration-test/README.md) for how integration coverage is collected. ## Project layout ``` src/tbd/ core.clj Router, Ring app, and server startup /routes.clj Each route group (health, math, ...) owns its route subtree /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 todo/ Per-user todos: pure rules (core), SQLite storage (db), `/api/todos` 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..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 (admin allowlist), or `:login? true` (any logged-in user; the user is passed to the handler as `:user`). A group with storage keeps it in its own `tbd..db`, apart from its business logic in `core.clj`.