From 66226b332b36ad707706c576b31700f80b68fa78 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:29:50 +0000 Subject: [PATCH] dream: router dispatch + path params + scopes + 27 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/conformance.sh | 4 +- lib/dream/router.sx | 129 +++++++++++++++++++++ lib/dream/tests/router.sx | 232 ++++++++++++++++++++++++++++++++++++++ plans/dream-on-sx.md | 11 +- 4 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 lib/dream/router.sx create mode 100644 lib/dream/tests/router.sx diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index 2ab78043..16de8614 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -23,11 +23,13 @@ VERBOSE="${1:-}" # Dream library modules loaded before any test suite. MODULES=( "lib/dream/types.sx" + "lib/dream/router.sx" ) # Suites: NAME RUNNER-FN PATH SUITES=( - "types dream-ty-tests-run! lib/dream/tests/types.sx" + "types dream-ty-tests-run! lib/dream/tests/types.sx" + "router dream-rt-tests-run! lib/dream/tests/router.sx" ) TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT diff --git a/lib/dream/router.sx b/lib/dream/router.sx new file mode 100644 index 00000000..723e69f6 --- /dev/null +++ b/lib/dream/router.sx @@ -0,0 +1,129 @@ +;; lib/dream/router.sx — Dream-on-SX routing. +;; Routes are dicts {:method :path :handler}; a router is a handler that +;; dispatches request -> response by method + path, extracting :name path +;; params and binding a ** catch-all. No match -> 404. Depends on types.sx. + +;; ── route constructors (one per HTTP method) ─────────────────────── +(define dream-get (fn (path handler) (dream-route "GET" path handler))) +(define dream-post (fn (path handler) (dream-route "POST" path handler))) +(define dream-put (fn (path handler) (dream-route "PUT" path handler))) +(define + dream-delete + (fn (path handler) (dream-route "DELETE" path handler))) +(define dream-patch (fn (path handler) (dream-route "PATCH" path handler))) +(define dream-head (fn (path handler) (dream-route "HEAD" path handler))) +(define + dream-options + (fn (path handler) (dream-route "OPTIONS" path handler))) +(define dream-any (fn (path handler) (dream-route "ANY" path handler))) + +;; ── path segmentation ────────────────────────────────────────────── +;; "/users/42/" -> ("users" "42"); "/" -> () +(define + dr/segs + (fn (path) (filter (fn (s) (not (= s ""))) (split path "/")))) + +(define + dr/join-path + (fn + (prefix path) + (str "/" (join "/" (concat (dr/segs prefix) (dr/segs path)))))) + +;; ── segment matching ─────────────────────────────────────────────── +;; Returns a params dict on match (possibly empty {}), nil on no match. +(define + dr/match-segs + (fn + (pat path params) + (cond + ((and (empty? pat) (empty? path)) params) + ((empty? pat) nil) + (else + (let + ((ps (first pat))) + (cond + ((= ps "**") (assoc params "**" (join "/" path))) + ((empty? path) nil) + ((starts-with? ps ":") + (dr/match-segs + (rest pat) + (rest path) + (assoc params (substr ps 1) (first path)))) + ((= ps (first path)) + (dr/match-segs (rest pat) (rest path) params)) + (else nil))))))) + +(define + dr/method-match? + (fn + (route-method req-method) + (or (= route-method "ANY") (= route-method req-method)))) + +;; ── middleware pipeline (shared with middleware.sx) ──────────────── +;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost. +(define + dr/apply-middlewares + (fn (mws handler) (reduce (fn (h mw) (mw h)) handler (reverse mws)))) + +;; ── scope: prefix mount + middleware chain ───────────────────────── +;; Returns a flat list of routes; nested scopes flatten correctly. +(define + dr/flatten-routes + (fn + (items) + (reduce + (fn + (acc it) + (if + (dream-route? it) + (concat acc (list it)) + (concat acc (dr/flatten-routes it)))) + (list) + items))) + +(define + dream-scope + (fn + (prefix middlewares routes) + (map + (fn + (r) + (dream-route + (dream-route-method r) + (dr/join-path prefix (dream-route-path r)) + (dr/apply-middlewares middlewares (dream-route-handler r)))) + (dr/flatten-routes routes)))) + +;; ── dispatch ─────────────────────────────────────────────────────── +(define + dr/try-route + (fn + (r req) + (if + (dr/method-match? (dream-route-method r) (dream-method req)) + (let + ((params (dr/match-segs (dr/segs (dream-route-path r)) (dr/segs (dream-path req)) {}))) + (if + (nil? params) + :no-match (dream-coerce-response + ((dream-route-handler r) (dream-with-params req params))))) + :no-match))) + +(define + dr/dispatch + (fn + (routes req) + (if + (empty? routes) + (dream-not-found) + (let + ((res (dr/try-route (first routes) req))) + (if (= res :no-match) (dr/dispatch (rest routes) req) res))))) + +(define + dream-router + (fn + (routes) + (let + ((flat (dr/flatten-routes routes))) + (fn (req) (dr/dispatch flat req))))) diff --git a/lib/dream/tests/router.sx b/lib/dream/tests/router.sx new file mode 100644 index 00000000..fc6568fd --- /dev/null +++ b/lib/dream/tests/router.sx @@ -0,0 +1,232 @@ +;; lib/dream/tests/router.sx — routing dispatch, path params, scopes. + +(define dream-rt-pass 0) +(define dream-rt-fail 0) +(define dream-rt-fails (list)) + +(define + dream-rt-test + (fn + (name actual expected) + (if + (= actual expected) + (set! dream-rt-pass (+ dream-rt-pass 1)) + (begin + (set! dream-rt-fail (+ dream-rt-fail 1)) + (append! dream-rt-fails {:name name :actual actual :expected expected}))))) + +(define + dream-rt-req + (fn (method target) (dream-request method target {} ""))) + +;; ── basic dispatch ───────────────────────────────────────────────── +(define + dream-rt-app + (dream-router + (list + (dream-get "/" (fn (req) (dream-text "home"))) + (dream-get "/about" (fn (req) (dream-text "about"))) + (dream-post "/submit" (fn (req) (dream-text "posted")))))) + +(dream-rt-test + "GET / -> home" + (dream-resp-body (dream-rt-app (dream-rt-req "GET" "/"))) + "home") +(dream-rt-test + "GET /about" + (dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about"))) + "about") +(dream-rt-test + "POST /submit" + (dream-resp-body (dream-rt-app (dream-rt-req "POST" "/submit"))) + "posted") +(dream-rt-test + "unknown path 404" + (dream-status (dream-rt-app (dream-rt-req "GET" "/nope"))) + 404) +(dream-rt-test + "wrong method 404" + (dream-status (dream-rt-app (dream-rt-req "GET" "/submit"))) + 404) +(dream-rt-test + "trailing slash equiv" + (dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about/"))) + "about") +(dream-rt-test + "query ignored for routing" + (dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about?x=1"))) + "about") + +;; ── path params ──────────────────────────────────────────────────── +(define + dream-rt-papp + (dream-router + (list + (dream-get + "/users/:id" + (fn (req) (dream-text (dream-param req "id")))) + (dream-get + "/users/:id/posts/:pid" + (fn + (req) + (dream-text + (str (dream-param req "id") "-" (dream-param req "pid"))))) + (dream-get + "/files/**" + (fn (req) (dream-text (dream-param req "**"))))))) + +(dream-rt-test + "single param" + (dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/users/42"))) + "42") +(dream-rt-test + "two params" + (dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/users/7/posts/9"))) + "7-9") +(dream-rt-test + "param no over-match" + (dream-status (dream-rt-papp (dream-rt-req "GET" "/users/7/extra"))) + 404) +(dream-rt-test + "catch-all captures rest" + (dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/files/a/b/c.txt"))) + "a/b/c.txt") +(dream-rt-test + "catch-all empty rest" + (dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/files/"))) + "") + +;; ── route order: first match wins ────────────────────────────────── +(define + dream-rt-order + (dream-router + (list + (dream-get "/x/specific" (fn (req) (dream-text "specific"))) + (dream-get "/x/:slug" (fn (req) (dream-text "generic")))))) +(dream-rt-test + "first match wins" + (dream-resp-body (dream-rt-order (dream-rt-req "GET" "/x/specific"))) + "specific") +(dream-rt-test + "fallthrough to param" + (dream-resp-body (dream-rt-order (dream-rt-req "GET" "/x/other"))) + "generic") + +;; ── ANY method ───────────────────────────────────────────────────── +(define + dream-rt-any + (dream-router + (list (dream-any "/ping" (fn (req) (dream-text (dream-method req))))))) +(dream-rt-test + "ANY matches GET" + (dream-resp-body (dream-rt-any (dream-rt-req "GET" "/ping"))) + "GET") +(dream-rt-test + "ANY matches DELETE" + (dream-resp-body (dream-rt-any (dream-rt-req "DELETE" "/ping"))) + "DELETE") + +;; ── handler returns bare string (coerced) ────────────────────────── +(define + dream-rt-coerce + (dream-router (list (dream-get "/s" (fn (req) "bare"))))) +(dream-rt-test + "string coerced to 200" + (dream-status (dream-rt-coerce (dream-rt-req "GET" "/s"))) + 200) +(dream-rt-test + "string coerced body" + (dream-resp-body (dream-rt-coerce (dream-rt-req "GET" "/s"))) + "bare") + +;; ── scope: prefix mount ──────────────────────────────────────────── +(define + dream-rt-scoped + (dream-router + (list + (dream-get "/" (fn (req) (dream-text "root"))) + (dream-scope + "/api" + (list) + (list + (dream-get "/users" (fn (req) (dream-text "api-users"))) + (dream-get + "/users/:id" + (fn + (req) + (dream-text (str "api-user-" (dream-param req "id")))))))))) +(dream-rt-test + "scope root still works" + (dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/"))) + "root") +(dream-rt-test + "scope prefix path" + (dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/api/users"))) + "api-users") +(dream-rt-test + "scope prefix param" + (dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/api/users/5"))) + "api-user-5") +(dream-rt-test + "scope unprefixed 404" + (dream-status (dream-rt-scoped (dream-rt-req "GET" "/users"))) + 404) + +;; ── scope: middleware applied to all routes ──────────────────────── +(define + dream-rt-mw + (fn (next) (fn (req) (dream-add-header (next req) "X-Scope" "on")))) +(define + dream-rt-mwapp + (dream-router + (list + (dream-scope + "/v1" + (list dream-rt-mw) + (list (dream-get "/a" (fn (req) (dream-text "a")))))))) +(dream-rt-test + "scope mw header" + (dream-resp-header (dream-rt-mwapp (dream-rt-req "GET" "/v1/a")) "x-scope") + "on") +(dream-rt-test + "scope mw body intact" + (dream-resp-body (dream-rt-mwapp (dream-rt-req "GET" "/v1/a"))) + "a") + +;; ── nested scopes ────────────────────────────────────────────────── +(define + dream-rt-outer + (fn (next) (fn (req) (dream-add-header (next req) "X-Outer" "1")))) +(define + dream-rt-inner + (fn (next) (fn (req) (dream-add-header (next req) "X-Inner" "1")))) +(define + dream-rt-nested + (dream-router + (list + (dream-scope + "/api" + (list dream-rt-outer) + (list + (dream-scope + "/v2" + (list dream-rt-inner) + (list (dream-get "/thing" (fn (req) (dream-text "thing")))))))))) +(dream-rt-test + "nested path" + (dream-resp-body (dream-rt-nested (dream-rt-req "GET" "/api/v2/thing"))) + "thing") +(dream-rt-test + "nested outer mw" + (dream-resp-header + (dream-rt-nested (dream-rt-req "GET" "/api/v2/thing")) + "x-outer") + "1") +(dream-rt-test + "nested inner mw" + (dream-resp-header + (dream-rt-nested (dream-rt-req "GET" "/api/v2/thing")) + "x-inner") + "1") + +(define dream-rt-tests-run! (fn () {:total (+ dream-rt-pass dream-rt-fail) :passed dream-rt-pass :failed dream-rt-fail :fails dream-rt-fails})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index 3cc80f94..555d54f6 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -45,7 +45,7 @@ The user-facing story: rose-ash users who'd never touch s-expressions might writ The five types: `request`, `response`, `handler = request -> response`, `middleware = handler -> handler`, `route`. Everything else is a function over these. - [x] **Core types** in `lib/dream/types.sx`: request/response records, route record. -- [ ] **Router** in `lib/dream/router.sx`: +- [x] **Router** in `lib/dream/router.sx`: - `dream-get path handler`, `dream-post path handler`, etc. for all HTTP methods. - `dream-scope prefix middlewares routes` — prefix mount with middleware chain. - `dream-router routes` — dispatch tree, returns handler; no match → 404. @@ -112,6 +112,15 @@ Confirm scope before starting; some of these may be addable as Dream-internal he constructors + accessors; smart response constructors (html/text/json/empty/ not-found/redirect); `dream-coerce-response` wraps bare strings; query-string parsing. Conformance runner `lib/dream/conformance.sh` modelled on flow's. +- **2026-06-07 — Router** (`lib/dream/router.sx`, 27 tests). `dream-get/post/put/ + delete/patch/head/options/any` route constructors; `dream-router` flattens routes + (incl. nested scopes) and dispatches by method+path, first-match-wins, 404 on no + match. Path matching is recursive over `/`-split segments: literal, `:name` binds + a param, `**` catch-all binds remaining path under key `"**"`. Trailing slashes and + query strings are ignored for routing. `dream-scope prefix mws routes` prepends the + prefix and folds the middleware chain (`m1 @@ m2 @@ h`, first = outermost) onto each + route's handler; nests correctly (inner mw innermost). Shared `dr/apply-middlewares` + fold will back `dream-pipeline`. ## Blockers