dream: router dispatch + path params + scopes + 27 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:29:50 +00:00
parent 8fc7469a3c
commit 66226b332b
4 changed files with 374 additions and 2 deletions

View File

@@ -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

129
lib/dream/router.sx Normal file
View File

@@ -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)))))

232
lib/dream/tests/router.sx Normal file
View File

@@ -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}))

View File

@@ -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