diff --git a/lib/dream/README.md b/lib/dream/README.md new file mode 100644 index 00000000..d7c533b5 --- /dev/null +++ b/lib/dream/README.md @@ -0,0 +1,79 @@ +# dream-on-sx + +OCaml's [Dream](https://aantron.github.io/dream/) web framework, reimplemented in +**plain SX** on the CEK evaluator. Dream is the cleanest middleware-shaped HTTP +framework in any language, and it maps onto SX with almost no impedance: + +| Dream | SX | +|-------|-----| +| `handler = request -> response promise` | `(fn (req) … (perform …))` | +| `middleware = handler -> handler` | `(fn (next) (fn (req) …))` | +| `m1 @@ m2 @@ handler` | `(m1 (m2 handler))` — left fold | +| `Dream.run handler` | `(dream-run handler)` → `(perform (:http/listen …))` | + +There are five types — **request, response, route**, and (as plain functions) +**handler** and **middleware**. Everything else is a function over them. + +## Quickstart + +```lisp +(dream-run + (dream-make-app + (list + (dream-get "/" (fn (req) (dream-html "

Hello, World!

"))) + (dream-get "/hello/:name" + (fn (req) (dream-text (str "Hi, " (dream-param req "name")))))))) +``` + +`dream-make-app` wraps the router in the default stack (error catch + content-type). +`dream-run` installs the root handler on the existing SX HTTP server — it does **not** +open its own socket. + +## Public surface + +- **types** — `dream-request`/`dream-response`/`dream-route`, accessors + (`dream-method`/`-path`/`-body`/`-header`/`-query-param`/`-param`), smart + constructors (`dream-html`/`-text`/`-json`/`-empty`/`-not-found`/`-redirect`), + convenience (`dream-queries`, `*-or` defaults, `dream-accepts?`/`dream-wants-json?`). +- **router** — `dream-get`/`-post`/`-put`/`-delete`/`-patch`/`-head`/`-options`/`-any`, + `dream-router`, `dream-scope` (prefix + middleware), `:name` params + `**` catch-all, + 405 + `Allow`, automatic HEAD. +- **middleware** — `dream-pipeline`, `dream-no-middleware`, `dream-logger`, + `dream-content-type`, `dream-set-header`, `dream-tap-request`. +- **session** — `dream-sessions` / `dream-sessions-signed`, `dream-session-field` / + `dream-set-session-field` / `dream-session-all` / `dream-invalidate-session`; cookie + helpers (`dream-cookie`, `dream-set-cookie`, `dream-cookie-sign`/`-unsign`). +- **flash** — `dream-flash`, `dream-add-flash-message`, `dream-flash-messages`. +- **form** — `dream-form` (Ok/Err), `dream-form-fields`, `dream-multipart`, CSRF + (`dream-csrf` / `dream-csrf-protect` / `dream-csrf-token` / `dream-csrf-tag`). +- **websocket** — `dream-websocket`, `dream-send`/`-receive`/`-close`/`-broadcast`. +- **static** — `dream-static` (mime, ETags, 304, ranges, traversal guard). +- **error** — `dream-catch`, `dream-status-text`/`-line`, `dream-status-page`. +- **cors** — `dream-cors`, `dream-cors-origin`, `dream-cors-with`. +- **json** — `dream-json-encode`/`-parse`, `dream-json-value`, `dream-json-body`. +- **run / api** — `dream-run`/`-port`/`-opts`, `dream-app`, `dream-make-app`, + `dream-serve`. + +## Testing story + +Every effectful concern is **dependency-injected**, so the whole framework is testable +without a running host: + +- sessions take a backend `(fn (op) …)` — `dream-memory-sessions` for tests, + `dream-perform-sessions` in production; +- static files take an fs — `dream-memory-fs` vs `dream-static-perform-fs`; +- websockets take an io — `dream-mock-ws` vs `dream-ws-perform-io`; +- `dream-run` takes a listen transport (`dream-run-with`). + +Run the suite: `bash lib/dream/conformance.sh` (367 tests, 14 suites). + +## Notes & caveats + +- Headers are dicts with **lowercased string keys** (in SX keywords *are* strings, so + `:content-type` == `"content-type"`). +- Outgoing cookies accumulate in a `:set-cookies` list on the response so multiple + `Set-Cookie` headers don't collide. +- The CSRF/cookie/ETag signing uses a pure-SX keyed hash — **not cryptographic**. + Production should inject a host HMAC (`dream-csrf-with`, and the signed-session + secret path). +- JSON and multipart are in-memory (not streaming). diff --git a/lib/dream/api.sx b/lib/dream/api.sx new file mode 100644 index 00000000..63b1850b --- /dev/null +++ b/lib/dream/api.sx @@ -0,0 +1,33 @@ +;; lib/dream/api.sx — Dream-on-SX public facade. +;; Loaded last; bundles the modules into a batteries-included surface. The full +;; public API is the `dream-*` functions across types/router/middleware/session/ +;; flash/form/websocket/static/error/cors/json/run; this file adds convenience +;; app builders. Depends on all other dream modules. + +(define dream-version "0.1.0") + +;; standard middleware stack (pure — no IO): error catch outermost, then +;; content-type sniffing. Logger is opt-in since it performs host IO. +(define + dream-defaults + (fn + (handler) + (dream-pipeline (list dream-catch dream-content-type) handler))) + +;; build a complete app handler from a route list with the default stack +(define + dream-make-app + (fn (routes) (dream-defaults (dream-router routes)))) + +;; build an app and wrap it with extra middleware (outermost first) +(define + dream-make-app-with + (fn + (middlewares routes) + (dream-pipeline middlewares (dream-make-app routes)))) + +;; one-call serve: routes + opts -> installed on the host +(define + dream-serve + (fn (routes opts) (dream-run-opts (dream-make-app routes) opts))) +(define dream-serve-port (fn (routes port) (dream-serve routes {:port port}))) diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index a66cfc35..0d634986 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -34,6 +34,7 @@ MODULES=( "lib/dream/cors.sx" "lib/dream/json.sx" "lib/dream/run.sx" + "lib/dream/api.sx" "lib/dream/demos/hello.sx" "lib/dream/demos/counter.sx" "lib/dream/demos/chat.sx" @@ -54,6 +55,7 @@ SUITES=( "cors dream-co-tests-run! lib/dream/tests/cors.sx" "json dream-js-tests-run! lib/dream/tests/json.sx" "run dream-rn-tests-run! lib/dream/tests/run.sx" + "api dream-ap-tests-run! lib/dream/tests/api.sx" "demos dream-dm-tests-run! lib/dream/tests/demos.sx" ) diff --git a/lib/dream/tests/api.sx b/lib/dream/tests/api.sx new file mode 100644 index 00000000..add9b71f --- /dev/null +++ b/lib/dream/tests/api.sx @@ -0,0 +1,77 @@ +;; lib/dream/tests/api.sx — facade: app builders + default stack. + +(define dream-ap-pass 0) +(define dream-ap-fail 0) +(define dream-ap-fails (list)) + +(define + dream-ap-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-ap-pass (+ dream-ap-pass 1)) + (begin + (set! dream-ap-fail (+ dream-ap-fail 1)) + (append! dream-ap-fails {:name name :actual actual :expected expected}))))) + +(dream-ap-test "version is a string" (string? dream-version) true) + +;; ── dream-make-app: routes -> handler with default stack ─────────── +(define + dream-ap-routes + (list + (dream-get "/" (fn (req) (dream-html "

hi

"))) + (dream-get "/boom" (fn (req) (error "kaboom"))) + (dream-get + "/raw" + (fn (req) (dream-response 200 {} "plain words"))))) +(define dream-ap-app (dream-make-app dream-ap-routes)) + +(dream-ap-test + "app serves" + (dream-resp-body (dream-ap-app (dream-request "GET" "/" {} ""))) + "

hi

") +(dream-ap-test + "app catches errors -> 500" + (dream-status (dream-ap-app (dream-request "GET" "/boom" {} ""))) + 500) +(dream-ap-test + "app 404 for unknown" + (dream-status (dream-ap-app (dream-request "GET" "/nope" {} ""))) + 404) +(dream-ap-test + "app sniffs content-type" + (dream-resp-header + (dream-ap-app (dream-request "GET" "/raw" {} "")) + "content-type") + "text/plain; charset=utf-8") + +;; ── dream-make-app-with: extra outer middleware ──────────────────── +(define + dream-ap-tag + (fn (next) (fn (req) (dream-add-header (next req) "X-App" "1")))) +(define + dream-ap-app2 + (dream-make-app-with (list dream-ap-tag) dream-ap-routes)) +(dream-ap-test + "extra middleware header" + (dream-resp-header + (dream-ap-app2 (dream-request "GET" "/" {} "")) + "x-app") + "1") + +;; ── dream-serve wires through dream-run ──────────────────────────── +(define dream-ap-captured nil) +(define dream-ap-listen (fn (op) (begin (set! dream-ap-captured op) :ok))) +(define + dream-ap-served + (dream-run-with dream-ap-listen (dream-make-app dream-ap-routes) {:port 7000})) +(dream-ap-test "serve listens" dream-ap-served :ok) +(dream-ap-test "serve port" (get dream-ap-captured :port) 7000) +(dream-ap-test + "served app runs" + (get ((get dream-ap-captured :app) {:method "GET" :target "/"}) :body) + "

hi

") + +(define dream-ap-tests-run! (fn () {:total (+ dream-ap-pass dream-ap-fail) :passed dream-ap-pass :failed dream-ap-fail :fails dream-ap-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 89344d77..913d4cb7 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -124,6 +124,12 @@ with extensions + hardening below. `dream-param-or` (defaults), `dream-has-query?` / `-header?` / `-param?`, `dream-content-type-of`, `dream-method-is?`, `dream-accepts?` / `dream-wants-json?` (Accept-header content negotiation). +- **2026-06-07 — Ext: api.sx facade + README** (`lib/dream/api.sx`, 9 tests, 367 total). + `dream-version`, `dream-defaults` (pure stack: error-catch + content-type; logger is + opt-in since it performs IO), `dream-make-app routes`, `dream-make-app-with`, + `dream-serve`/`dream-serve-port`. `lib/dream/README.md` documents the full public + surface, quickstart, the dependency-injection testing story, and caveats. **All + planned extensions complete — 367/367 across 14 suites.** ## Extensions (post-roadmap) @@ -137,7 +143,7 @@ The five-types core is complete; these harden it toward a production HTTP front - [x] **Signed session cookies** (`dream-sessions-signed` — tamper-evident sid). - [x] **JSON helpers** (encode + recursive-descent parse, pure SX). - [x] **Query/header convenience** (`dream-queries`, `*-or` defaults, `dream-accepts?`). -- [ ] **`api.sx` facade + README** — single load point listing the public surface. +- [x] **`api.sx` facade + README** — `dream-make-app` / `dream-serve` + `README.md`. ## Stdlib additions Dream will need