writeonce/docs/examples/web-app/main.wo
shoney.arickathil 40127bf53e feat: framework v1 slice 2 — the remaining ledger, ten items
- After seam: interface After + Aw + use_after; dispatch funnels every
  response (handler/short-circuit/404/405) through the after chain;
  the WS 101 sentinel skips it (never serialized)
- http/secure.wo: SecurityHeaders (nosniff/DENY/referrer; HSTS stays
  at the TLS proxy), Cors (preflight 204 before + origin stamp after,
  one class both halves), HostAllow (421), client_ip (XFF parsing —
  peer VERIFY stays story 35)
- http/nego.wo: accepts() (exact, type/*, */*; q stripped not ranked),
  etag_for (quoted base64 sha256), with_etag (If-None-Match -> 304)
- router: *rest wildcard (last segment, empty rest matches), Group
  (prefix + routes + group middleware) + Gmw prefix-scoped entries,
  App.mount; new App fields carry defaults so standing ctor literals
  keep compiling
- Req grows ctx bag; parse rejects duplicate Content-Length (400,
  RFC 9112 §6.3)
- web-app exercises all of it; gate grows 26 -> 38 checks (wildcards,
  group+ctx, etag+304, 406/200 negotiation, sec headers, 421,
  preflight+origin stamp, dup-CL 400)
- ledger rows flipped; dep graph section 3 grown (slice-2 done nodes,
  crypto gate cleared, cookie/CSRF/session/webhook/JWT now ready)
- merges: chat-ws-lifecycle (digests for ETag; WS + lifecycle ride
  along) + site-sample (second consumer gate); battery 13/13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 06:45:51 +02:00

212 lines
7.5 KiB
Text

-- web-app — the storefront: writeonce-framework (via [deps]) + @table
-- persistence. Every handler is a class satisfying Handler; the auth gate is
-- a Middleware; the data layer is the language's own database — no ORM, no
-- separate process, one binary.
use env
use json
use framework
use framework/http
use framework/router
-- decode target for POST /products, encode shape for every product answer
typedef ProductView = { name: Text, price: Float, stock: Int }
typedef NewOrder = { product: Text, qty: Int }
fn view_json(name: Text, price: Float, stock: Int) -> Text {
return json.encode(ProductView { name: name, price: price, stock: stock });
}
class ListProducts {
pad: Int
fn handle(req: Req) -> Resp {
let body = "[";
let first = true;
for p in from x in Product order by x.name select x {
if first == false { body = body .. ","; }
first = false;
body = body .. view_json(p.name, p.price, p.stock);
}
return ok_json(body .. "]");
}
}
class ShowProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
let hits = from p in Product where p.name == name take 1 select p;
if len(hits) == 0 { return not_found(); }
let p = hits[0];
return ok_json(view_json(p.name, p.price, p.stock));
}
}
-- Accepts THREE bodies: multipart/form-data (curl -F), a form post
-- (application/x-www-form-urlencoded), and JSON — same insert either way.
fn create_product(name: Text, price: Float, stock: Int) -> Resp {
let made = try insert Product { name: name, price: price, stock: stock }
catch (e) nil;
if made == nil { return conflict("product name already exists"); }
return created_json(view_json(name, price, stock));
}
class CreateProduct {
pad: Int
fn handle(req: Req) -> Resp {
if media_type(req) == "multipart/form-data" {
let ps = multipart_parts(req);
if ps == nil { return bad_request("unreadable multipart body"); }
let name = part_named(ps, "name");
if name == nil { return bad_request("multipart needs name, price, stock"); }
let pstr = part_named(ps, "price");
if pstr == nil { return bad_request("multipart needs name, price, stock"); }
let sstr = part_named(ps, "stock");
if sstr == nil { return bad_request("multipart needs name, price, stock"); }
-- parse_float answers NaN for unparseable input rather than a ?Float:
-- "not a number" is already a Float value, and NaN != NaN is the test
let price = parse_float(pstr);
if price != price { return bad_request("price must be a number"); }
let stock = parse_int(sstr);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);
}
if media_type(req) == "application/x-www-form-urlencoded" {
let f = form_values(req);
if f == nil { return bad_request("unreadable form body"); }
let name = f["name"];
if name == nil { return bad_request("form needs name, price, stock"); }
let ps = f["price"];
if ps == nil { return bad_request("form needs name, price, stock"); }
let ss = f["stock"];
if ss == nil { return bad_request("form needs name, price, stock"); }
let price = parse_float(ps);
if price != price { return bad_request("price must be a number"); }
let stock = parse_int(ss);
if stock == nil { return bad_request("stock must be a number"); }
return create_product(name, price, stock);
}
let v = json.decode(req.body) as ProductView;
if v == nil { return bad_request("body must be {name, price, stock}"); }
return create_product(v.name, v.price, v.stock);
}
}
class CreateOrder {
pad: Int
fn handle(req: Req) -> Resp {
let v = json.decode(req.body) as NewOrder;
if v == nil { return bad_request("body must be {product, qty}"); }
if v.qty < 1 { return bad_request("qty must be positive"); }
let hits = from p in Product where p.name == v.product take 1 select p;
if len(hits) == 0 { return not_found(); }
insert Order { product: hits[0], qty: v.qty };
return created_json("{\"ok\":true}");
}
}
class DeleteProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
let hits = from p in Product where p.name == name take 1 select p;
if len(hits) == 0 { return not_found(); }
let gone = try delete hits[0] catch (e) nil;
if gone == nil { return conflict("orders still reference this product"); }
return ok_json("{\"deleted\":true}");
}
}
-- ---- framework v1 slice 2: the storefront exercises the new surface ----
-- wildcard capture: GET /files/*path echoes the rest
class EchoPath {
pad: Int
fn handle(req: Req) -> Resp {
let p = req.params["path"];
if p == nil { return ok_text("path="); }
return ok_text("path=${p}");
}
}
-- group middleware writes the request-scoped ctx bag; the handler reads it
class StampCtx {
pad: Int
fn before(mut req: Req) -> ?Resp {
req.ctx["via"] = "api-group";
return nil;
}
}
class ApiPing {
pad: Int
fn handle(req: Req) -> Resp {
let via = req.ctx["via"];
if via == nil { return ok_text("pong via="); }
return ok_text("pong via=${via}");
}
}
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
class EtagProbe {
pad: Int
fn handle(req: Req) -> Resp {
return with_etag(req, ok_json("{\"v\":1}"));
}
}
-- response-side negotiation: JSON or nothing
class NegoProbe {
pad: Int
fn handle(req: Req) -> Resp {
if accepts(req, "application/json") == false {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
return Resp { status: 406, headers: h, body: "{\"error\":\"json only\"}" };
}
return ok_json("{\"ok\":true}");
}
}
fn main(args: multi Text) -> Int {
if len(args) < 1 {
print_err("usage: web-app <port> (WA_TOKEN and WO_DATA must be set)");
return 2;
}
let port = parse_int(args[0]);
if port == nil {
print_err("web-app: <port> must be a number");
return 2;
}
let token = env.get("WA_TOKEN");
if token == nil {
print_err("web-app: WA_TOKEN is required (the auth middleware's bearer token)");
return 2;
}
let app = App { middleware: [], routes: [] };
-- v1 slice 2: host gate first (421 before anything runs), CORS preflight
-- next, then the framework's Bearer mechanism (constant-time compare,
-- principal attached to req.principal for handlers that want "who")
app.use_mw(Mw { m: HostAllow { host: "a" } });
app.use_mw(Mw { m: Cors { allow_origin: "*" } });
app.use_mw(Mw { m: BearerAuth { token: token, principal: "api" } });
-- the response half: security headers + the CORS origin stamp on every
-- response that leaves dispatch (404/405/401 included)
app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
app.use_after(Aw { a: Cors { allow_origin: "*" } });
app.get("/products", ListProducts { pad: 0 });
app.get("/products/:name", ShowProduct { pad: 0 });
app.post("/products", CreateProduct { pad: 0 });
app.post("/orders", CreateOrder { pad: 0 });
app.delete_("/products/:name", DeleteProduct { pad: 0 });
app.get("/files/*path", EchoPath { pad: 0 });
app.get("/etag-probe", EtagProbe { pad: 0 });
app.get("/nego", NegoProbe { pad: 0 });
let g = Group { prefix: "/api" };
g.use_mw(Mw { m: StampCtx { pad: 0 } });
g.get("/ping", ApiPing { pad: 0 });
app.mount(g);
return app.serve("127.0.0.1", port);
}