dream: router 405 Method Not Allowed + Allow header + automatic HEAD + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
;; lib/dream/router.sx — Dream-on-SX routing.
|
;; lib/dream/router.sx — Dream-on-SX routing.
|
||||||
;; Routes are dicts {:method :path :handler}; a router is a handler that
|
;; Routes are dicts {:method :path :handler}; a router is a handler that
|
||||||
;; dispatches request -> response by method + path, extracting :name path
|
;; 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) ───────────────────────
|
;; ── route constructors (one per HTTP method) ───────────────────────
|
||||||
(define dream-get (fn (path handler) (dream-route "GET" path handler)))
|
(define dream-get (fn (path handler) (dream-route "GET" path handler)))
|
||||||
@@ -53,11 +55,25 @@
|
|||||||
(dr/match-segs (rest pat) (rest path) params))
|
(dr/match-segs (rest pat) (rest path) params))
|
||||||
(else nil)))))))
|
(else nil)))))))
|
||||||
|
|
||||||
|
;; path-only match: returns params dict or nil
|
||||||
(define
|
(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
|
(fn
|
||||||
(route-method req-method)
|
(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) ────────────────
|
;; ── middleware pipeline (shared with middleware.sx) ────────────────
|
||||||
;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost.
|
;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost.
|
||||||
@@ -95,30 +111,55 @@
|
|||||||
(dr/flatten-routes routes))))
|
(dr/flatten-routes routes))))
|
||||||
|
|
||||||
;; ── dispatch ───────────────────────────────────────────────────────
|
;; ── dispatch ───────────────────────────────────────────────────────
|
||||||
(define
|
;; allowed = methods of routes whose PATH matched (for 405 + Allow).
|
||||||
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
|
(define
|
||||||
dr/dispatch
|
dr/dispatch
|
||||||
(fn
|
(fn
|
||||||
(routes req)
|
(routes req allowed)
|
||||||
(if
|
(if
|
||||||
(empty? routes)
|
(empty? routes)
|
||||||
(dream-not-found)
|
(if
|
||||||
|
(empty? allowed)
|
||||||
|
(dream-not-found)
|
||||||
|
(dream-method-not-allowed allowed))
|
||||||
(let
|
(let
|
||||||
((res (dr/try-route (first routes) req)))
|
((r (first routes)))
|
||||||
(if (= res :no-match) (dr/dispatch (rest routes) req) res)))))
|
(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
|
(define
|
||||||
dream-router
|
dream-router
|
||||||
@@ -126,4 +167,4 @@
|
|||||||
(routes)
|
(routes)
|
||||||
(let
|
(let
|
||||||
((flat (dr/flatten-routes routes)))
|
((flat (dr/flatten-routes routes)))
|
||||||
(fn (req) (dr/dispatch flat req)))))
|
(fn (req) (dr/dispatch flat req (list))))))
|
||||||
|
|||||||
@@ -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-pass 0)
|
||||||
(define dream-rt-fail 0)
|
(define dream-rt-fail 0)
|
||||||
@@ -45,9 +45,9 @@
|
|||||||
(dream-status (dream-rt-app (dream-rt-req "GET" "/nope")))
|
(dream-status (dream-rt-app (dream-rt-req "GET" "/nope")))
|
||||||
404)
|
404)
|
||||||
(dream-rt-test
|
(dream-rt-test
|
||||||
"wrong method 404"
|
"wrong method 405"
|
||||||
(dream-status (dream-rt-app (dream-rt-req "GET" "/submit")))
|
(dream-status (dream-rt-app (dream-rt-req "GET" "/submit")))
|
||||||
404)
|
405)
|
||||||
(dream-rt-test
|
(dream-rt-test
|
||||||
"trailing slash equiv"
|
"trailing slash equiv"
|
||||||
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about/")))
|
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about/")))
|
||||||
@@ -229,4 +229,44 @@
|
|||||||
"x-inner")
|
"x-inner")
|
||||||
"1")
|
"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 "<p>hi</p>"))))))
|
||||||
|
(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}))
|
(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}))
|
||||||
|
|||||||
@@ -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] `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] `chat.ml` → `lib/dream/demos/chat.sx`: multi-room WebSocket chat.
|
||||||
- [x] `todo.ml` → `lib/dream/demos/todo.sx`: CRUD list with forms + CSRF.
|
- [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
|
## Stdlib additions Dream will need
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user