197 lines
10 KiB
Markdown
197 lines
10 KiB
Markdown
# 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 `<BASE_URL>/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
|
|
<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
|
|
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.<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 (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.<group>.db`, apart
|
|
from its business logic in `core.clj`.
|