From 67484f799a62ab27860aba46c358d4679d26773f Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 19 Aug 2026 19:53:19 +0200 Subject: [PATCH] feat(framework): router + Handler/Middleware + App (iter 16 Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - router/router.wo: Handler (handle(req) -> Resp) and Middleware (before(req) -> ?Resp; nil = continue) structural interfaces; Route/Mw record classes built as ctor literals at the registration site — the ownership shape the corpus pins (run/container-owned-move); route_match with :param captures over '/'-split segments (empties dropped, first mismatch wins). - app.wo: App holds the middleware chain + route table (take-push), satisfies http's Dispatcher, dispatches middleware-then-first-match, fills the captures onto the borrowed request in place (Dispatcher takes `mut req` — the borrow checker rightly refused rebuilding a Req from borrowed maps; captures collect locally so a failed match never touches the request), 404 fallback, serve(host, port) delegation. Verified against a throwaway app (not committed): middleware 401 short-circuit without the token; /things/:id captures 42 into the body; unknown path 404; a DIV0 handler answers 500 and the next request is served; SIGTERM clean. Framework image emits at 12161 bytes. Co-Authored-By: Claude Opus 5 (1M context) --- docs/examples/writeonce-framework/app.wo | 50 ++++++++++++++++ .../writeonce-framework/http/serve.wo | 2 +- .../writeonce-framework/router/router.wo | 57 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 docs/examples/writeonce-framework/app.wo create mode 100644 docs/examples/writeonce-framework/router/router.wo diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo new file mode 100644 index 0000000..72106c5 --- /dev/null +++ b/docs/examples/writeonce-framework/app.wo @@ -0,0 +1,50 @@ +-- app.wo — the assembly: an App holds the middleware chain and the route +-- table, satisfies http's Dispatcher interface, and serves. +-- +-- let app = App { middleware: [], routes: [] }; +-- app.use_mw(Mw { m: Auth { token: t } }); +-- app.add(Route { method: "GET", pattern: "/products/:id", h: Show {} }); +-- return app.serve("127.0.0.1", port); +-- +-- Dispatch order: middleware in registration order (a Resp short-circuits), +-- then the first matching route (method + pattern), else the framework 404. +-- The serve loop wraps dispatch in `try`, so a trapping handler answers 500 +-- and the server survives. +use http +use router + +pub class App { + middleware: multi Mw + routes: multi Route + + fn use_mw(take m: Mw) { + push(self.middleware, m); + } + + fn add(take r: Route) { + push(self.routes, r); + } + + fn dispatch(mut req: Req) -> Resp { + for mw in self.middleware { + let short = mw.m.before(req); + if short != nil { return short; } + } + for r in self.routes { + if r.method == req.method { + let params: map = {}; + if route_match(r.pattern, req.path, params) { + -- captures land on the borrowed request itself (mut) — a failed + -- match above never touched it, since captures collect locally + for k, v in params { req.params[k] = v; } + return r.h.handle(req); + } + } + } + return not_found(); + } + + fn serve(host: Text, port: Int) -> Int { + return http.serve(host, port, self); + } +} diff --git a/docs/examples/writeonce-framework/http/serve.wo b/docs/examples/writeonce-framework/http/serve.wo index 2c238de..a900805 100644 --- a/docs/examples/writeonce-framework/http/serve.wo +++ b/docs/examples/writeonce-framework/http/serve.wo @@ -9,7 +9,7 @@ use net use env pub interface Dispatcher { - fn dispatch(req: Req) -> Resp + fn dispatch(mut req: Req) -> Resp } fn status_text(code: Int) -> Text { diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo new file mode 100644 index 0000000..1faa45d --- /dev/null +++ b/docs/examples/writeonce-framework/router/router.wo @@ -0,0 +1,57 @@ +-- router/router.wo — the handler model and the route table. +-- +-- No function values in this language, by doctrine — so a handler is a +-- CLASS satisfying the Handler interface (its fields are the closure +-- substitute), dispatched structurally (ICALL). A class missing `handle` +-- is a compile error (WO-E205). Middleware is its own interface because +-- the signature differs: `before` returns `?Resp` — nil means continue, +-- a Resp short-circuits (401 gates, redirects). + +pub interface Handler { + fn handle(req: Req) -> Resp +} + +pub interface Middleware { + fn before(req: Req) -> ?Resp +} + +-- One route: method + pattern + the handler value. Built with a ctor +-- literal at the registration site (`Route { method: "GET", pattern: +-- "/products/:id", h: ProductShow {} }`) — the exact ownership shape the +-- conformance corpus pins (run/container-owned-move). +pub class Route { + method: Text + pattern: Text + h: Handler +} + +-- Middleware wrapper record, same shape as Route for the same reason. +pub class Mw { + m: Middleware +} + +-- Does `pattern` match `path`? Fills `params` with :name captures. +-- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the +-- root "/" is the empty segment list). First mismatch wins; a :segment +-- captures anything non-empty. +pub fn route_match(pattern: Text, path: Text, mut params: map) -> Bool { + let ps = split(pattern, "/"); + let xs = split(path, "/"); + let psegs: multi Text = []; + for s in ps { if s != "" { push(psegs, s); } } + let xsegs: multi Text = []; + for s in xs { if s != "" { push(xsegs, s); } } + if len(psegs) != len(xsegs) { return false; } + let i = 0; + while i < len(psegs) { + let p = psegs[i]; + let x = xsegs[i]; + if starts_with(p, ":") { + params[substr(p, 1, len(p) - 1)] = x; + } else { + if p != x { return false; } + } + i = i + 1; + } + return true; +}