From 078872728e93f70ca0aadf6234e97b4218bfee12 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 15:00:29 +0000 Subject: [PATCH] dream: router 405 Method Not Allowed + Allow header + automatic HEAD + 9 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/router.sx | 85 +++++++++++++++++++++++++++++---------- lib/dream/tests/router.sx | 46 +++++++++++++++++++-- plans/dream-on-sx.md | 28 ++++++++++++- 3 files changed, 133 insertions(+), 26 deletions(-) diff --git a/lib/dream/router.sx b/lib/dream/router.sx index 723e69f6..9158ee7d 100644 --- a/lib/dream/router.sx +++ b/lib/dream/router.sx @@ -1,7 +1,9 @@ ;; 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. +;; params and binding a ** catch-all. No path match -> 404; path matches but +;; method doesn't -> 405 + Allow. HEAD falls back to the GET handler with an +;; empty body. Depends on types.sx. ;; ── route constructors (one per HTTP method) ─────────────────────── (define dream-get (fn (path handler) (dream-route "GET" path handler))) @@ -53,11 +55,25 @@ (dr/match-segs (rest pat) (rest path) params)) (else nil))))))) +;; path-only match: returns params dict or nil (define - dr/method-match? + dr/route-params + (fn + (r req) + (dr/match-segs + (dr/segs (dream-route-path r)) + (dr/segs (dream-path req)) + {}))) + +;; method acceptance: exact, ANY, or HEAD served by a GET route +(define + dr/method-accepts? (fn (route-method req-method) - (or (= route-method "ANY") (= route-method req-method)))) + (or + (= route-method "ANY") + (= route-method req-method) + (and (= req-method "HEAD") (= route-method "GET"))))) ;; ── middleware pipeline (shared with middleware.sx) ──────────────── ;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost. @@ -95,30 +111,55 @@ (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))) - +;; allowed = methods of routes whose PATH matched (for 405 + Allow). (define dr/dispatch (fn - (routes req) + (routes req allowed) (if (empty? routes) - (dream-not-found) + (if + (empty? allowed) + (dream-not-found) + (dream-method-not-allowed allowed)) (let - ((res (dr/try-route (first routes) req))) - (if (= res :no-match) (dr/dispatch (rest routes) req) res))))) + ((r (first routes))) + (let + ((params (dr/route-params r req))) + (if + (nil? params) + (dr/dispatch (rest routes) req allowed) + (if + (dr/method-accepts? (dream-route-method r) (dream-method req)) + (dr/run-route r req params) + (dr/dispatch + (rest routes) + req + (concat allowed (list (dream-route-method r))))))))))) + +;; run a matched route; blank the body for an auto-HEAD on a GET route +(define + dr/run-route + (fn + (r req params) + (let + ((resp (dream-coerce-response ((dream-route-handler r) (dream-with-params req params))))) + (if + (and + (= (dream-method req) "HEAD") + (not (= (dream-route-method r) "HEAD"))) + (dream-response (dream-status resp) (dream-headers resp) "") + resp)))) + +;; 405 response with an Allow header listing the path's methods +(define + dream-method-not-allowed + (fn + (allowed) + (dream-add-header + (dream-response 405 {:content-type "text/plain; charset=utf-8"} "Method Not Allowed") + "allow" + (join ", " allowed)))) (define dream-router @@ -126,4 +167,4 @@ (routes) (let ((flat (dr/flatten-routes routes))) - (fn (req) (dr/dispatch flat req))))) + (fn (req) (dr/dispatch flat req (list)))))) diff --git a/lib/dream/tests/router.sx b/lib/dream/tests/router.sx index fc6568fd..61f064f5 100644 --- a/lib/dream/tests/router.sx +++ b/lib/dream/tests/router.sx @@ -1,4 +1,4 @@ -;; lib/dream/tests/router.sx — routing dispatch, path params, scopes. +;; lib/dream/tests/router.sx — routing dispatch, path params, scopes, 405/HEAD. (define dream-rt-pass 0) (define dream-rt-fail 0) @@ -45,9 +45,9 @@ (dream-status (dream-rt-app (dream-rt-req "GET" "/nope"))) 404) (dream-rt-test - "wrong method 404" + "wrong method 405" (dream-status (dream-rt-app (dream-rt-req "GET" "/submit"))) - 404) + 405) (dream-rt-test "trailing slash equiv" (dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about/"))) @@ -229,4 +229,44 @@ "x-inner") "1") +;; ── 405 Method Not Allowed + Allow ───────────────────────────────── +(define + dream-rt-mapp + (dream-router + (list + (dream-get "/r" (fn (req) (dream-text "get"))) + (dream-post "/r" (fn (req) (dream-text "post"))) + (dream-get "/only" (fn (req) (dream-html "

hi

")))))) +(define dream-rt-405 (dream-rt-mapp (dream-rt-req "DELETE" "/r"))) +(dream-rt-test "405 status" (dream-status dream-rt-405) 405) +(dream-rt-test + "405 Allow has GET" + (contains? (dream-resp-header dream-rt-405 "allow") "GET") + true) +(dream-rt-test + "405 Allow has POST" + (contains? (dream-resp-header dream-rt-405 "allow") "POST") + true) +(dream-rt-test + "matching method still works" + (dream-resp-body (dream-rt-mapp (dream-rt-req "POST" "/r"))) + "post") +(dream-rt-test + "no path is 404 not 405" + (dream-status (dream-rt-mapp (dream-rt-req "DELETE" "/absent"))) + 404) + +;; ── automatic HEAD (serve GET, empty body) ───────────────────────── +(define dream-rt-head (dream-rt-mapp (dream-rt-req "HEAD" "/only"))) +(dream-rt-test "HEAD status 200" (dream-status dream-rt-head) 200) +(dream-rt-test "HEAD empty body" (dream-resp-body dream-rt-head) "") +(dream-rt-test + "HEAD keeps content-type" + (dream-resp-header dream-rt-head "content-type") + "text/html; charset=utf-8") +(dream-rt-test + "HEAD on missing path 404" + (dream-status (dream-rt-mapp (dream-rt-req "HEAD" "/none"))) + 404) + (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 7ff8e2e6..88e7e476 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -79,7 +79,33 @@ The five types: `request`, `response`, `handler = request -> response`, `middlew - [x] `counter.ml` → `lib/dream/demos/counter.sx`: in-memory counter with sessions. - [x] `chat.ml` → `lib/dream/demos/chat.sx`: multi-room WebSocket chat. - [x] `todo.ml` → `lib/dream/demos/todo.sx`: CRUD list with forms + CSRF. -- [ ] Tests in `lib/dream/tests/`: routing dispatch, middleware composition, session round-trip, CSRF accept/reject, flash read-after-write — 60+ tests. +- [x] Tests in `lib/dream/tests/`: routing dispatch, middleware composition, session round-trip, CSRF accept/reject, flash read-after-write — **258 tests across 10 suites** (well past the 60+ target). Runner: `lib/dream/conformance.sh`. + +**Roadmap complete (2026-06-07): all boxes ticked, 258/258 green.** Loop continues +with extensions + hardening below. +- **2026-06-07 — Ext: router HTTP correctness** (router suite 27→36, 267 total). + Dispatch now tracks which routes' *paths* matched: path matched + method didn't → + `405 Method Not Allowed` with an `Allow` header listing the path's methods (was a + blanket 404); genuinely-absent paths stay 404. `HEAD` falls back to the matching + `GET` handler with the body blanked but headers kept. `dr/route-params` (path-only + match) + `dr/method-accepts?` (ANY / HEAD→GET) + `dream-method-not-allowed`. NOTE: + in this worktree every `sx-tree` *edit* tool (`sx_replace_node`, + `sx_replace_by_pattern`, `sx_insert_near`) raises a yojson `Expected string, got + null` error — only `sx_write_file` works, so edits rewrite the whole file. + +## Extensions (post-roadmap) + +The five-types core is complete; these harden it toward a production HTTP front door. + +- [x] **Router HTTP correctness**: 405 Method Not Allowed + `Allow` header; automatic + HEAD (serve the GET handler with an empty body). +- [ ] **Status reason phrases** + `dream-status-text`. +- [ ] **CORS middleware** (`dream-cors`). +- [ ] **Error-handling middleware** (`dream-catch` / custom 404 + 500 templates). +- [ ] **Signed session cookies** (the noted hardening — sign the sid). +- [ ] **JSON helpers** (build from dict; parse to dict). +- [ ] **Query/header convenience** (`dream-queries`, defaults). +- [ ] **`api.sx` facade + README** — single load point listing the public surface. ## Stdlib additions Dream will need