diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml index bb35ef0..872cac7 100644 --- a/compiler/bin/main.ml +++ b/compiler/bin/main.ml @@ -484,6 +484,39 @@ let compile_image ?(deps : (string * string) list = []) path = in let collector = Woc_lib.Diag.Collector.create () 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 `/...` — 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 units = List.map diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo new file mode 100644 index 0000000..b0d349a --- /dev/null +++ b/docs/examples/web-app/main.wo @@ -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 (WA_TOKEN and WO_DATA must be set)"); + return 2; + } + let port = parse_int(args[0]); + if port == nil { + print_err("web-app: 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); +} diff --git a/docs/examples/web-app/types.wo b/docs/examples/web-app/types.wo new file mode 100644 index 0000000..520d9f0 --- /dev/null +++ b/docs/examples/web-app/types.wo @@ -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 +} diff --git a/docs/examples/web-app/wo.toml b/docs/examples/web-app/wo.toml index d959e21..56c7e81 100644 --- a/docs/examples/web-app/wo.toml +++ b/docs/examples/web-app/wo.toml @@ -13,5 +13,7 @@ wo = ">= 0.1" # gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote # built from docs/examples/writeonce-framework, so CI never needs the # 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] -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" }