feat: web-app storefront + dep-relative use resolution (iter 16 Task 4)
- docs/examples/web-app: Product (@unique name, backlink orders) / Order (ref Product) as @table classes; handlers as Handler classes — ListProducts (ordered query -> JSON array), ShowProduct (unique-index probe, 404), CreateProduct (checked json.decode -> 400; @unique trap -> 409), CreateOrder (FK insert), DeleteProduct (FK restrict trap -> 409); Auth middleware reads WA_TOKEN. Entry validates port + token honestly. - The [deps] KEY is the module name `use` imports: hyphens are not identifier characters, so the app keys the dep `framework` while the repository keeps its long name (recorded in the manifest comment). - driver fix (real gap the chain exposed): a dependency's INTERNAL `use` paths are written against its own root (`use http` inside the framework) but compile under `<depname>/...` — compile_image now prefixes dep files' use paths with the dep name (stdlib namespaces stay bare; a path already starting with the dep name is untouched). Verified end to end through the full chain (temp git remote of the framework, file:// substituted, fetch -> lock -> build -> serve): 401 without the token; [] empty list; 201 create; 409 duplicate (@unique); 400 malformed json; list/show payloads exact; 404 unknown product; 201 order; 409 delete-while-referenced (FK restrict) with the server still serving; SIGTERM clean; the product survives a process restart (WAL replay). Gates: woc-test 540/0, oop-e2e 88/0, deps-accept 8/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67484f799a
commit
24c991069d
4 changed files with 172 additions and 1 deletions
|
|
@ -484,6 +484,39 @@ let compile_image ?(deps : (string * string) list = []) path =
|
||||||
in
|
in
|
||||||
let collector = Woc_lib.Diag.Collector.create () in
|
let collector = Woc_lib.Diag.Collector.create () in
|
||||||
let parsed = parse_all collector sources in
|
let parsed = parse_all collector sources in
|
||||||
|
(* A dependency's INTERNAL `use` paths are written relative to the dep's
|
||||||
|
own root (`use http` inside the framework), but under this compile its
|
||||||
|
modules live at `<depname>/...` — so prefix each dep file's use paths
|
||||||
|
with the dep name. Reserved stdlib namespaces stay bare, and a path
|
||||||
|
already starting with the dep's own name is left alone. *)
|
||||||
|
let parsed =
|
||||||
|
if deps = [] then parsed
|
||||||
|
else
|
||||||
|
List.map
|
||||||
|
(fun (f, prog) ->
|
||||||
|
let owner =
|
||||||
|
List.find_opt
|
||||||
|
(fun (_, droot) ->
|
||||||
|
let prefix = droot ^ "/" in
|
||||||
|
let plen = String.length prefix in
|
||||||
|
String.length f >= plen && String.sub f 0 plen = prefix)
|
||||||
|
deps
|
||||||
|
in
|
||||||
|
match owner with
|
||||||
|
| None -> (f, prog)
|
||||||
|
| Some (dname, _) ->
|
||||||
|
let redecl = function
|
||||||
|
| Woc_lib.Ast.Use u ->
|
||||||
|
(match u.Woc_lib.Ast.segments with
|
||||||
|
| seg :: _
|
||||||
|
when (not (Woc_lib.Types.is_stdlib_module seg)) && seg <> dname ->
|
||||||
|
Woc_lib.Ast.Use { u with Woc_lib.Ast.segments = dname :: u.Woc_lib.Ast.segments }
|
||||||
|
| _ -> Woc_lib.Ast.Use u)
|
||||||
|
| d -> d
|
||||||
|
in
|
||||||
|
(f, { Woc_lib.Ast.decls = List.map redecl prog.Woc_lib.Ast.decls }))
|
||||||
|
parsed
|
||||||
|
in
|
||||||
let syms, module_syms = typecheck_all collector ~root:path ~deps parsed in
|
let syms, module_syms = typecheck_all collector ~root:path ~deps parsed in
|
||||||
let units =
|
let units =
|
||||||
List.map
|
List.map
|
||||||
|
|
|
||||||
117
docs/examples/web-app/main.wo
Normal file
117
docs/examples/web-app/main.wo
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
-- 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: Int, stock: Int }
|
||||||
|
typedef NewOrder = { product: Text, qty: Int }
|
||||||
|
|
||||||
|
fn view_json(name: Text, price: Int, stock: Int) -> Text {
|
||||||
|
return json.encode(ProductView { name: name, price: price, stock: stock });
|
||||||
|
}
|
||||||
|
|
||||||
|
class Auth {
|
||||||
|
token: Text
|
||||||
|
fn before(req: Req) -> ?Resp {
|
||||||
|
let got = req.headers["authorization"];
|
||||||
|
if got == nil { return unauthorized(); }
|
||||||
|
if got != "Bearer ${self.token}" { return unauthorized(); }
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateProduct {
|
||||||
|
pad: Int
|
||||||
|
fn handle(req: Req) -> Resp {
|
||||||
|
let v = json.decode(req.body) as ProductView;
|
||||||
|
if v == nil { return bad_request("body must be {name, price, stock}"); }
|
||||||
|
let made = try insert Product { name: v.name, price: v.price, stock: v.stock }
|
||||||
|
catch (e) nil;
|
||||||
|
if made == nil { return conflict("product name already exists"); }
|
||||||
|
return created_json(view_json(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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [] };
|
||||||
|
app.use_mw(Mw { m: Auth { token: token } });
|
||||||
|
app.add(Route { method: "GET", pattern: "/products", h: ListProducts { pad: 0 } });
|
||||||
|
app.add(Route { method: "GET", pattern: "/products/:name", h: ShowProduct { pad: 0 } });
|
||||||
|
app.add(Route { method: "POST", pattern: "/products", h: CreateProduct { pad: 0 } });
|
||||||
|
app.add(Route { method: "POST", pattern: "/orders", h: CreateOrder { pad: 0 } });
|
||||||
|
app.add(Route { method: "DELETE", pattern: "/products/:name", h: DeleteProduct { pad: 0 } });
|
||||||
|
return app.serve("127.0.0.1", port);
|
||||||
|
}
|
||||||
19
docs/examples/web-app/types.wo
Normal file
19
docs/examples/web-app/types.wo
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- The store's data model. Every class IS a table (iteration 9/9b): rows
|
||||||
|
-- persist through the WAL under WO_DATA, `name` is unique-indexed, and an
|
||||||
|
-- Order's `ref Product` is a real FK — deleting a product that orders still
|
||||||
|
-- reference traps WO_T_FK (restrict), which the delete route surfaces as 409.
|
||||||
|
|
||||||
|
@table(name: "products", index: [name])
|
||||||
|
class Product {
|
||||||
|
name: Text @unique
|
||||||
|
price: Int -- cents
|
||||||
|
stock: Int
|
||||||
|
|
||||||
|
orders: backlink Order.product
|
||||||
|
}
|
||||||
|
|
||||||
|
@table(name: "orders", index: [product])
|
||||||
|
class Order {
|
||||||
|
product: ref Product
|
||||||
|
qty: Int
|
||||||
|
}
|
||||||
|
|
@ -13,5 +13,7 @@ wo = ">= 0.1"
|
||||||
# gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote
|
# gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote
|
||||||
# built from docs/examples/writeonce-framework, so CI never needs the
|
# built from docs/examples/writeonce-framework, so CI never needs the
|
||||||
# network and this repo never carries .wo-deps/wo.lock artifacts.
|
# network and this repo never carries .wo-deps/wo.lock artifacts.
|
||||||
|
# The [deps] KEY is the module name `use` imports (hyphens are not identifier
|
||||||
|
# characters, so the key is `framework` while the repository keeps its name).
|
||||||
[deps]
|
[deps]
|
||||||
writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
|
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue