patx/relay-lang
1. Decorator-level schema args (@app.post(..., validate=...), plus query/body/json)
Commit c57ccca · patx · 2026-02-11T23:33:06-05:00
1. Decorator-level schema args (@app.post(..., validate=...), plus query/body/json)
- Parser now accepts decorator kwargs: validate, query, body/form, json: src/main.rs:776
- Decorator metadata is stored and carried into route specs: src/main.rs:147, src/main.rs:3718
- Decorator schemas are resolved at function registration time: src/main.rs:2413
- Runtime enforces these schemas before handler binding and returns structured 400 validation_error on failure: src/main.rs:5572
- Validation helpers and schema engine used by decorator validation: src/main.rs:3172, src/main.rs:3483
2. Full middleware next() chain semantics
- Added recursive middleware chain executor with real next() continuation behavior: src/main.rs:2294
- Middleware now supports:
- fn mw()
- fn mw(ctx)
- fn mw(ctx, next) with actual chain execution
- call_web_handler now runs middleware through the chain executor before final handler output: src/main.rs:5839
3. Auth password hashing + store backends, plus pluggable session backend
- Added Argon2 password hashing:
- auth_hash_password(password): src/main.rs:3327
- auth_verify_password(password, hash): src/main.rs:3342
- Added AuthStore():
- memory backend by default
- callback backend via AuthStore(load_fn, save_fn): src/main.rs:3362
- store methods (register, verify, get_hash, set_hash): src/main.rs:4856
- Added pluggable session backend:
- app state backend config: src/main.rs:3668
- callback backend API app.session_backend(load_fn, save_fn): src/main.rs:5383
- runtime session load/save through callback or memory backend: src/main.rs:2244
Docs updated
- Decorator schema options: README.md:748
- Middleware next() semantics: README.md:852
- Pluggable session backend usage: README.md:1016
- Auth helpers and AuthStore: README.md:1089
- Feature list updated accordingly: README.md:50
Validation
- Ran cargo test successfully.
- Result: 27 passed, 0 failed.
Files changed
- src/main.rs
- README.md
- Cargo.toml
- Cargo.lock
Comments
No comments yet.
Diff
diff --git a/Cargo.lock b/Cargo.lock
index 57a3220..13cec8a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -30,6 +30,18 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -140,6 +152,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -164,6 +182,15 @@ dependencies = [
"wyz",
]
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1287,6 +1314,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "pbkdf2"
version = "0.11.0"
@@ -1512,6 +1550,7 @@ name = "relay"
version = "0.1.0"
dependencies = [
"anyhow",
+ "argon2",
"async-recursion",
"axum",
"futures",
diff --git a/Cargo.toml b/Cargo.toml
index 8034f35..d893180 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,3 +20,4 @@ mongodb = { version = "2.8", features = ["tokio-runtime"] }
futures = "0.3"
minijinja = "2"
url = "2"
+argon2 = "0.5"
diff --git a/README.md b/README.md
index 7037266..d929b08 100644
--- a/README.md
+++ b/README.md
@@ -40,11 +40,16 @@ Relay is designed for building high-performance web services, APIs, and network
- **Implicit Async**: No `await` keyword needed—async operations resolve automatically when values are accessed
- **Python-like Syntax**: Clean, indentation-based syntax that's easy to read and write
- **Built-in Web Server**: Flask/FastAPI-style decorators with automatic routing
+- **Route Groups + OpenAPI**: Prefix route groups and expose generated `/openapi.json`
- **Static File Serving**: Mount directories directly in your web app (`app.static("/assets", "./public")`)
- **Middleware Hooks**: Run request middleware before handlers (`app.use(auth_middleware)`)
-- **HTTP Client**: Simple, async HTTP requests built-in
+- **HTTP Client**: Async HTTP client with `get/post/put/patch/delete` and request/response headers
- **MongoDB Integration**: Native async MongoDB support
- **Session Management**: Built-in session handling with HttpOnly cookies
+- **Pluggable Session Backends**: Use in-memory sessions or custom load/save callbacks
+- **Authentication Helpers**: Password hashing/verification and pluggable auth stores
+- **Structured API Errors**: `HTTPError(...)` helper with consistent JSON error envelope
+- **Request IDs**: Per-request `request_id` in handler context and `x-request-id` response header
- **Template Rendering**: MiniJinja-powered templates available in any string expression (`{{ variable }}`)
- **Type Hints**: Optional runtime type checking for function parameters
- **List Comprehensions**: Python-style inline list transforms with optional filtering
@@ -372,10 +377,13 @@ result = add(5, 10) // OK
result = add("5", "10") // Type error!
```
+Type hints on regular functions are strict validation (no implicit coercion). Unknown type hints raise a runtime type error.
+
**Supported Types:**
- `str`: String
- `int`: Integer
- `float`: Float
+- `bool`: Boolean
- `json` or `Json`: JSON object
```relay
@@ -654,27 +662,37 @@ Create an HTTP client instance.
http = Http()
```
-#### `http.get(url)`
+#### `http.get(url, headers=None)`
**Returns:** `Deferred<Response>`
Send a GET request.
```relay
http = Http()
-resp = http.get("https://api.example.com/users")
+resp = http.get(
+ "https://api.example.com/users",
+ headers={"authorization": "Bearer token"}
+)
print(resp.status) // 200
print(resp.text) // Response body as string
```
-#### `http.post(url, data=None)`
+#### `http.post(url, data=None, headers=None)`
+#### `http.put(url, data=None, headers=None)`
+#### `http.patch(url, data=None, headers=None)`
+#### `http.delete(url, data=None, headers=None)`
**Returns:** `Deferred<Response>`
-Send a POST request with JSON body.
+Send requests with optional payloads and headers.
```relay
http = Http()
payload = {"name": "Ada", "email": "[email protected]"}
-resp = http.post("https://api.example.com/users", payload)
+resp = http.post(
+ "https://api.example.com/users",
+ payload,
+ headers={"content-type": "application/json"}
+)
```
#### Response Object
@@ -684,6 +702,7 @@ HTTP responses have the following properties:
- `resp.status` - HTTP status code (int)
- `resp.text` - Response body as string
- `resp.json()` - Parse response body as JSON
+- `resp.headers` - Response headers (dict)
```relay
http = Http()
@@ -726,6 +745,43 @@ fn create_user(name: str, email: str)
return {"id": 123, "name": name, "email": email}
```
+Decorator schema options:
+- `validate=...` - Validate/coerce body payload (JSON first, then form)
+- `query=...` - Validate/coerce query parameters
+- `body=...` - Validate/coerce form body
+- `json=...` - Validate/coerce JSON body
+
+```relay
[email protected]("/search", query={"limit": "int", "q?": "str"})
+fn search(limit, q = None)
+ return {"limit": limit, "q": q}
+
[email protected]("/users", json={"name": "str", "age?": "int"})
+fn create_user(name, age = None)
+ return {"name": name, "age": age}
+```
+
+#### Route Groups and OpenAPI
+
+Use grouped route prefixes to organize larger APIs:
+
+```relay
+app = WebApp()
+api = app.group("/api")
+v1 = api.group("/v1")
+
[email protected]("/users/<user_id>")
+fn get_user(user_id)
+ return {"id": user_id}
+```
+
+Enable generated OpenAPI docs:
+
+```relay
+app.openapi(title="Relay API", version="1.0.0")
+// Exposes GET /openapi.json
+```
+
#### WebSocket Routes
Use `@app.ws(path)` for WebSocket upgrade endpoints.
@@ -791,6 +847,29 @@ fn create_event(data: Json)
return {"event_type": data["type"], "user": data["user"]}
```
+For zero-arg handlers (or explicit access), use request helpers: `get_query()`, `get_body()`, and `get_json()`.
+
+#### Middleware
+
+Register middleware with `app.use(fn)`.
+
+Middleware functions can be:
+- `fn middleware()` (legacy style)
+- `fn middleware(ctx)` where `ctx` contains request fields
+- `fn middleware(ctx, next)` with full chain semantics
+
+If middleware returns a non-`None` value, Relay short-circuits and sends that response.
+
+`next()` runs the remainder of the middleware chain and then the handler.
+
+```relay
+fn audit(ctx, next)
+ print("before:", ctx["path"])
+ result = next()
+ print("after:", ctx["path"])
+ return result
+```
+
#### Type Hints in Handlers
Use type hints to enforce parameter types and enable automatic coercion:
@@ -808,6 +887,7 @@ fn calculate(a: int, b: int)
- `str` - String
- `int` - Integer
- `float` - Float
+- `bool` - Boolean
- `json` or `Json` - Full JSON body (for POST/PUT/PATCH)
#### Request Object
@@ -819,6 +899,7 @@ Every handler has access to a `request` dictionary:
fn debug_request()
print(request["method"]) // GET
print(request["path"]) // /debug
+ print(request["request_id"])// rid_...
print(request["query"]) // Query parameters dict
print(request["form"]) // Form fields dict (if present)
print(request["json"]) // JSON body (if present)
@@ -830,12 +911,55 @@ fn debug_request()
**Request fields:**
- `method` - HTTP method (string)
- `path` - Request path (string)
+- `request_id` - Request identifier string
- `query` - Query parameters (dict)
- `form` - Parsed form body fields (dict, when present)
- `headers` - Request headers (dict)
- `cookies` - Cookies (dict)
- `json` - Parsed JSON body (if present)
+#### Request Helpers
+
+Use helpers when you want payload access without binding handler parameters:
+
+- `get_query()` - Query parameters as a dict (empty dict when unavailable)
+- `get_body()` - Parsed form fields as a dict (empty dict when unavailable)
+- `get_json()` - Parsed JSON body (or `None` when unavailable)
+
+```relay
[email protected]("/search")
+fn search()
+ query = get_query()
+ return {"q": query["q"]}
+
[email protected]("/submit")
+fn submit()
+ form = get_body()
+ payload = get_json()
+ return {"form": form, "json": payload}
+```
+
+#### Validation Helpers
+
+Use schema helpers for explicit request validation:
+
+- `validate(data, schema)` - Validate/coerce an object against schema
+- `require_query(schema)` - Validate query params in handlers
+- `require_body(schema)` - Validate form body in handlers
+- `require_json(schema)` - Validate JSON body in handlers
+
+Schema format:
+- `"field": "type"` for required fields
+- `"field?": "type"` for optional fields
+- `"field": {"type": "int", "required": True, "default": 10}` for explicit rules
+
+```relay
[email protected]("/search")
+fn search()
+ params = require_query({"limit": "int", "q?": "str"})
+ return {"limit": params["limit"], "q": params["q"]}
+```
+
#### Cookies
Access cookies via the `cookies` dict:
@@ -869,9 +993,29 @@ fn profile()
**Session features:**
- Automatically persisted across requests
- Stored server-side (not in cookies)
-- Uses HttpOnly `relay_sid` cookie
+- Uses `relay_sid` cookie (`HttpOnly`, `SameSite=Lax`, `Secure` automatically on HTTPS)
- Session data is a dictionary that persists modifications
+Customize cookie policy:
+
+```relay
+app.session(secure=True, http_only=True, same_site="Lax")
+```
+
+Use a custom session backend (for any database/service):
+
+```relay
+session_db = {}
+
+fn load_session(sid)
+ return session_db[sid]
+
+fn save_session(sid, data)
+ session_db[sid] = data
+
+app.session_backend(load_session, save_session)
+```
+
#### Response Types
Handlers can return various types:
@@ -930,6 +1074,60 @@ fn get_xml()
- `status` - HTTP status code (default: 200)
- `content_type` - Content-Type header (auto-detected if not specified)
+#### `HTTPError(status, code, message, details=None)`
+Create a structured API error response.
+When called inside a handler, Relay also includes `request_id` in the error payload.
+
+```relay
[email protected]("/users")
+fn create_user(name)
+ if (name == None)
+ return HTTPError(400, "validation_error", "Missing name", {"field": "name"})
+ return {"ok": True}
+```
+
+#### Authentication Helpers
+
+#### `auth_hash_password(password)`
+**Returns:** password hash string
+
+#### `auth_verify_password(password, hash)`
+**Returns:** `bool`
+
+```relay
+hash = auth_hash_password("super-secret")
+is_valid = auth_verify_password("super-secret", hash) // True
+```
+
+#### `AuthStore(load_fn=None, save_fn=None)`
+Create an authentication store.
+
+- `AuthStore()` uses in-memory storage.
+- `AuthStore(load_fn, save_fn)` uses custom callbacks for pluggable backends.
+
+Available methods:
+- `store.register(username, password)` - hashes and stores password
+- `store.verify(username, password)` - verifies against stored hash
+- `store.get_hash(username)` - returns stored hash or `None`
+- `store.set_hash(username, hash)` - stores precomputed hash
+
+```relay
+store = AuthStore()
+store.register("ada", "pw")
+print(store.verify("ada", "pw")) // True
+
+auth_db = {}
+fn load_user(name)
+ return auth_db[name]
+
+fn save_user(name, hash)
+ auth_db[name] = hash
+
+custom = AuthStore(load_user, save_user)
+custom.register("bob", "pw2")
+print(custom.verify("bob", "pw2")) // True
+```
+
#### `app.redirect(url)`
**Returns:** `Response` with 302 status
@@ -1538,11 +1736,13 @@ The web server uses Axum's routing system:
### Session Storage
Sessions are stored server-side in an in-memory hash map:
-- Session ID is generated using UUID
-- `relay_sid` cookie stores the session ID (HttpOnly, SameSite=Lax)
+- Session ID is generated by Relay at runtime
+- `relay_sid` cookie stores the session ID (`HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS)
- Session data persists across requests for the same session ID
- Sessions are stored in memory (cleared on server restart)
+Every web request is also assigned a `request_id` and echoed as the `x-request-id` response header.
+
**Note:** In production, you'd want to persist sessions to a database.
### MongoDB Integration
@@ -1904,7 +2104,7 @@ Found a bug? [Open an issue](https://github.com/patx/relay-lang/issues) with:
**Known Gaps (Identified in v0.1):**
- [ ] JSON request key binding to scalar handler args (e.g. bind `{"name":"Ada"}` directly to `fn create(name)`).
-- [ ] HTTP client parity for `put`, `patch`, `delete`, request headers, and response header access.
+- [x] HTTP client parity for `put`, `patch`, `delete`, request headers, and response header access.
- [ ] First-class CLI flags (`--help`, `--version`) for better install verification and discoverability.
- [ ] Built-in HTML escaping helper for safely rendering user content directly in server-side templates.
diff --git a/test2.ry b/examples/param_types.ry
similarity index 100%
rename from test2.ry
rename to examples/param_types.ry
diff --git a/test.ry b/examples/pastebin.ry
similarity index 100%
rename from test.ry
rename to examples/pastebin.ry
diff --git a/examples/type_hint_validation.ry b/examples/type_hint_validation.ry
new file mode 100644
index 0000000..c021ac0
--- /dev/null
+++ b/examples/type_hint_validation.ry
@@ -0,0 +1,23 @@
+print("== Untyped Function (no validation) ==")
+
+// No type hints: accepts any value types.
+fn pair(a, b)
+ return [a, b]
+
+print(pair(1, "two"))
+print(pair({"name": "Ada"}, [10, 20]))
+
+print("== Typed Function (runtime validation) ==")
+
+// Type hints: arguments must match declared types.
+fn add_ints(a: int, b: int)
+ return a + b
+
+print(add_ints(5, 7))
+
+try
+ // Avoid wrapping in print(...) first; print is async and can hide this error.
+ bad = add_ints("5", "7")
+ print("Unexpected success: " + str(bad))
+except(err)
+ print("Type validation error: " + str(err))
diff --git a/src/main.rs b/src/main.rs
index 3492b07..b328f20 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,6 +31,10 @@ use axum::{
};
use async_recursion::async_recursion;
+use argon2::{
+ password_hash::{SaltString, rand_core::OsRng},
+ Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
+};
use futures::{stream::TryStreamExt, SinkExt, StreamExt};
use indexmap::IndexMap;
use minijinja::Environment;
@@ -140,6 +144,10 @@ enum Decorator {
base: String, // variable name holding Route handle
method: String,
path: String,
+ validate_schema: Option<Expr>,
+ query_schema: Option<Expr>,
+ body_schema: Option<Expr>,
+ json_schema: Option<Expr>,
},
}
@@ -775,8 +783,49 @@ impl Parser {
Tok::Str(s) => s,
_ => return Err(self.err_here("Decorator expects string path")),
};
+ let mut validate_schema = None;
+ let mut query_schema = None;
+ let mut body_schema = None;
+ let mut json_schema = None;
+
+ if self.peek_is(&Tok::Comma) {
+ self.bump();
+ while !self.peek_is(&Tok::RParen) {
+ let key = self.expect_ident()?;
+ self.expect(&Tok::Assign)?;
+ let value = self.parse_expr()?;
+ match key.as_str() {
+ "validate" => validate_schema = Some(value),
+ "query" => query_schema = Some(value),
+ "body" | "form" => body_schema = Some(value),
+ "json" => json_schema = Some(value),
+ _ => {
+ return Err(self.err_here(
+ "Decorator option must be one of: validate, query, body, json",
+ ))
+ }
+ }
+
+ if self.peek_is(&Tok::Comma) {
+ self.bump();
+ if self.peek_is(&Tok::RParen) {
+ break;
+ }
+ continue;
+ }
+ break;
+ }
+ }
self.expect(&Tok::RParen)?;
- Ok(Decorator::Route { base, method, path })
+ Ok(Decorator::Route {
+ base,
+ method,
+ path,
+ validate_schema,
+ query_schema,
+ body_schema,
+ json_schema,
+ })
}
fn parse_func_def(&mut self, decorators: Vec<Decorator>) -> RResult<Stmt> {
@@ -1324,6 +1373,7 @@ enum Object {
WebServer(WebServerHandle),
Http(HttpHandle),
HttpResponse(HttpResponseHandle),
+ AuthStore(AuthStoreHandle),
MongoClient(MongoClientHandle),
MongoDatabase(MongoDatabaseHandle),
MongoCollection(MongoCollectionHandle),
@@ -1530,6 +1580,7 @@ fn render_template(s: &str, locals: &HashMap<String, Value>) -> RResult<String>
// ========================= Evaluator (async, implicit Deferred) =========================
+#[derive(Clone)]
struct Evaluator {
env: Arc<tokio::sync::Mutex<Env>>,
module_state: Arc<tokio::sync::Mutex<ModuleState>>,
@@ -2154,11 +2205,11 @@ impl Evaluator {
}
}
- // type hints: enforce minimal (str/int/float/Json)
+ // type hints: strict runtime validation (no implicit coercion)
for p in &f.params {
if let Some(ty) = &p.ty {
if let Some(v) = bound.get(&p.name).cloned() {
- bound.insert(p.name.clone(), coerce_param_type(ty, v)?);
+ bound.insert(p.name.clone(), validate_param_type(ty, v)?);
}
}
}
@@ -2182,6 +2233,121 @@ impl Evaluator {
Ok(result)
}
+ async fn call_named_function(&self, name: &str, args: Vec<Value>) -> RResult<Value> {
+ let callee = { self.env.lock().await.get(name)? };
+ let out = self.call_value(callee, args, vec![]).await?;
+ self.resolve_if_needed(out).await
+ }
+
+ async fn load_session_data(
+ &self,
+ app: &WebAppHandle,
+ session_id: &Option<String>,
+ ) -> RResult<IndexMap<String, Value>> {
+ let Some(sid) = session_id.clone() else {
+ return Ok(IndexMap::new());
+ };
+
+ match app.session_backend() {
+ SessionBackendConfig::Memory => Ok(app.get_session(&sid)),
+ SessionBackendConfig::Callback { load_fn, .. } => {
+ let out = self
+ .call_named_function(&load_fn, vec![Value::Str(sid)])
+ .await?;
+ match out {
+ Value::None => Ok(IndexMap::new()),
+ Value::Dict(m) => Ok(m),
+ Value::Json(J::Object(obj)) => Ok(obj
+ .iter()
+ .map(|(k, v)| (k.clone(), json_to_value(v)))
+ .collect()),
+ other => Err(RelayError::Type(format!(
+ "session load callback must return dict/json/None, got {}",
+ value_type_name(&other)
+ ))),
+ }
+ }
+ }
+ }
+
+ async fn persist_session_data(
+ &self,
+ app: &WebAppHandle,
+ session_id: String,
+ data: IndexMap<String, Value>,
+ ) -> RResult<()> {
+ match app.session_backend() {
+ SessionBackendConfig::Memory => {
+ app.put_session(session_id, data);
+ Ok(())
+ }
+ SessionBackendConfig::Callback { save_fn, .. } => {
+ let _ = self
+ .call_named_function(&save_fn, vec![Value::Str(session_id), Value::Dict(data)])
+ .await?;
+ Ok(())
+ }
+ }
+ }
+
+ #[async_recursion]
+ async fn run_middleware_chain(
+ self: Arc<Self>,
+ middlewares: Arc<Vec<Arc<UserFunction>>>,
+ index: usize,
+ ctx: Value,
+ handler: Arc<dyn Fn() -> BoxFut + Send + Sync>,
+ ) -> RResult<Value> {
+ if index >= middlewares.len() {
+ return (handler)().await;
+ }
+
+ let mw = middlewares[index].clone();
+ let mut args = Vec::new();
+ if !mw.params.is_empty() {
+ args.push(ctx.clone());
+ }
+
+ let next_result = Arc::new(tokio::sync::Mutex::new(None::<Value>));
+ if mw.params.len() >= 2 {
+ let evaluator = self.clone();
+ let middlewares = middlewares.clone();
+ let handler = handler.clone();
+ let ctx_for_next = ctx.clone();
+ let next_result_cell = next_result.clone();
+ args.push(Value::Builtin(Arc::new(move |_args, _kwargs| {
+ let evaluator = evaluator.clone();
+ let middlewares = middlewares.clone();
+ let handler = handler.clone();
+ let ctx_for_next = ctx_for_next.clone();
+ let next_result_cell = next_result_cell.clone();
+ Box::pin(async move {
+ let out = evaluator
+ .run_middleware_chain(middlewares, index + 1, ctx_for_next, handler)
+ .await?;
+ {
+ let mut cell = next_result_cell.lock().await;
+ *cell = Some(out.clone());
+ }
+ Ok(out)
+ })
+ })));
+ }
+
+ let result = self.call_user_fn(&mw, args, vec![]).await?;
+ if !matches!(result, Value::None) {
+ return Ok(result);
+ }
+
+ if mw.params.len() >= 2 {
+ let from_next = next_result.lock().await.clone();
+ return Ok(from_next.unwrap_or(Value::None));
+ }
+
+ self.run_middleware_chain(middlewares, index + 1, ctx, handler)
+ .await
+ }
+
async fn get_member(&self, obj: Value, name: &str) -> RResult<Value> {
match obj {
Value::Obj(o) => o.get_member(name),
@@ -2240,17 +2406,53 @@ impl Evaluator {
) -> RResult<()> {
for d in decorators {
match d {
- Decorator::Route { base, method, path } => {
+ Decorator::Route {
+ base,
+ method,
+ path,
+ validate_schema,
+ query_schema,
+ body_schema,
+ json_schema,
+ } => {
+ let mut validation = RouteValidation::default();
+ if let Some(expr) = validate_schema {
+ validation.validate_schema =
+ Some(self.resolve_if_needed(self.eval_expr(expr).await?).await?);
+ }
+ if let Some(expr) = query_schema {
+ validation.query_schema =
+ Some(self.resolve_if_needed(self.eval_expr(expr).await?).await?);
+ }
+ if let Some(expr) = body_schema {
+ validation.body_schema =
+ Some(self.resolve_if_needed(self.eval_expr(expr).await?).await?);
+ }
+ if let Some(expr) = json_schema {
+ validation.json_schema =
+ Some(self.resolve_if_needed(self.eval_expr(expr).await?).await?);
+ }
+
let base_val = self.env.lock().await.get(base)?;
let base_val = self.resolve_if_needed(base_val).await?;
match base_val {
// New spec: @app.get("/path") where `app` is a WebApp
Value::Obj(Object::WebApp(a)) => {
- a.register(method.clone(), path.clone(), fn_name.to_string());
+ a.register(
+ method.clone(),
+ path.clone(),
+ fn_name.to_string(),
+ validation.clone(),
+ );
}
// Back-compat: route = app.route(); @route.get("/path")
Value::Obj(Object::Route(r)) => {
- r.register(method.clone(), path.clone(), fn_name.to_string());
+ r.register(
+ method.clone(),
+ path.clone(),
+ fn_name.to_string(),
+ validation.clone(),
+ );
}
_ => {
return Err(RelayError::Type(format!(
@@ -2265,6 +2467,75 @@ impl Evaluator {
}
}
+fn value_type_name(v: &Value) -> &'static str {
+ match v {
+ Value::Int(_) => "int",
+ Value::Float(_) => "float",
+ Value::Bool(_) => "bool",
+ Value::Str(_) => "str",
+ Value::None => "None",
+ Value::List(_) => "list",
+ Value::Dict(_) => "dict",
+ Value::Json(_) => "Json",
+ Value::Bytes(_) => "bytes",
+ Value::Function(_) => "function",
+ Value::Builtin(_) => "builtin",
+ Value::Deferred(_) => "Deferred",
+ Value::Task(_) => "Task",
+ Value::Thunk(_) => "Thunk",
+ Value::Obj(_) => "object",
+ Value::Response(_) => "Response",
+ }
+}
+
+fn validate_param_type(ty: &str, v: Value) -> RResult<Value> {
+ match ty {
+ "str" => match v {
+ Value::Str(s) => Ok(Value::Str(s)),
+ other => Err(RelayError::Type(format!(
+ "Expected str, got {}",
+ value_type_name(&other)
+ ))),
+ },
+ "int" => match v {
+ Value::Int(n) => Ok(Value::Int(n)),
+ other => Err(RelayError::Type(format!(
+ "Expected int, got {}",
+ value_type_name(&other)
+ ))),
+ },
+ "float" => match v {
+ Value::Float(f) => Ok(Value::Float(f)),
+ other => Err(RelayError::Type(format!(
+ "Expected float, got {}",
+ value_type_name(&other)
+ ))),
+ },
+ "bool" => match v {
+ Value::Bool(b) => Ok(Value::Bool(b)),
+ other => Err(RelayError::Type(format!(
+ "Expected bool, got {}",
+ value_type_name(&other)
+ ))),
+ },
+ "Json" | "json" => match v {
+ Value::Json(j) => Ok(Value::Json(j)),
+ Value::Dict(m) => {
+ let mut obj = serde_json::Map::new();
+ for (k, vv) in m {
+ obj.insert(k, value_to_json(&vv));
+ }
+ Ok(Value::Json(J::Object(obj)))
+ }
+ other => Err(RelayError::Type(format!(
+ "Expected Json, got {}",
+ value_type_name(&other)
+ ))),
+ },
+ _ => Err(RelayError::Type(format!("Unknown type hint: {ty}"))),
+ }
+}
+
fn coerce_param_type(ty: &str, v: Value) -> RResult<Value> {
match ty {
"str" => Ok(Value::Str(v.repr())),
@@ -2288,6 +2559,16 @@ fn coerce_param_type(ty: &str, v: Value) -> RResult<Value> {
.map_err(|_| RelayError::Type("Bad float".into())),
_ => Err(RelayError::Type("Bad float".into())),
},
+ "bool" => match v {
+ Value::Bool(b) => Ok(Value::Bool(b)),
+ Value::Int(n) => Ok(Value::Bool(n != 0)),
+ Value::Str(s) => match s.trim().to_ascii_lowercase().as_str() {
+ "true" | "1" | "yes" | "on" => Ok(Value::Bool(true)),
+ "false" | "0" | "no" | "off" => Ok(Value::Bool(false)),
+ _ => Err(RelayError::Type("Bad bool".into())),
+ },
+ _ => Err(RelayError::Type("Bad bool".into())),
+ },
"Json" | "json" => match v {
Value::Json(j) => Ok(Value::Json(j)),
Value::Dict(m) => {
@@ -2302,7 +2583,7 @@ fn coerce_param_type(ty: &str, v: Value) -> RResult<Value> {
.map_err(|_| RelayError::Type("Bad Json".into())),
_ => Err(RelayError::Type("Bad Json".into())),
},
- _ => Ok(v),
+ _ => Err(RelayError::Type(format!("Unknown type hint: {ty}"))),
}
}
@@ -2539,6 +2820,128 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
})),
);
+ let evaluator_for_get_query = evaluator.clone();
+ env.set(
+ "get_query",
+ Value::Builtin(Arc::new(move |_args, _| {
+ let evaluator = evaluator_for_get_query.clone();
+ Box::pin(async move {
+ let request = {
+ let env = evaluator.env.lock().await;
+ env.get("request").ok()
+ };
+
+ match request {
+ Some(Value::Dict(req)) => {
+ Ok(req
+ .get("query")
+ .cloned()
+ .unwrap_or(Value::Dict(IndexMap::new())))
+ }
+ _ => Ok(Value::Dict(IndexMap::new())),
+ }
+ })
+ })),
+ );
+
+ env.set(
+ "validate",
+ Value::Builtin(Arc::new(|args, _| {
+ Box::pin(async move {
+ let data = args.get(0).cloned().ok_or_else(|| {
+ RelayError::Type("validate(data, schema) missing data".into())
+ })?;
+ let schema = args.get(1).cloned().ok_or_else(|| {
+ RelayError::Type("validate(data, schema) missing schema".into())
+ })?;
+ validate_data_with_schema(data, schema, "validate(data, schema)")
+ })
+ })),
+ );
+
+ let evaluator_for_require_query = evaluator.clone();
+ env.set(
+ "require_query",
+ Value::Builtin(Arc::new(move |args, _| {
+ let evaluator = evaluator_for_require_query.clone();
+ Box::pin(async move {
+ let schema = args.get(0).cloned().ok_or_else(|| {
+ RelayError::Type("require_query(schema) missing schema".into())
+ })?;
+ let request = {
+ let env = evaluator.env.lock().await;
+ env.get("request").ok()
+ };
+ let data = match request {
+ Some(Value::Dict(req)) => req
+ .get("query")
+ .cloned()
+ .unwrap_or(Value::Dict(IndexMap::new())),
+ _ => Value::Dict(IndexMap::new()),
+ };
+ validate_data_with_schema(data, schema, "require_query(schema)")
+ })
+ })),
+ );
+
+ let evaluator_for_require_body = evaluator.clone();
+ env.set(
+ "require_body",
+ Value::Builtin(Arc::new(move |args, _| {
+ let evaluator = evaluator_for_require_body.clone();
+ Box::pin(async move {
+ let schema = args.get(0).cloned().ok_or_else(|| {
+ RelayError::Type("require_body(schema) missing schema".into())
+ })?;
+ let request = {
+ let env = evaluator.env.lock().await;
+ env.get("request").ok()
+ };
+ let data = match request {
+ Some(Value::Dict(req)) => req
+ .get("form")
+ .cloned()
+ .unwrap_or(Value::Dict(IndexMap::new())),
+ _ => Value::Dict(IndexMap::new()),
+ };
+ validate_data_with_schema(data, schema, "require_body(schema)")
+ })
+ })),
+ );
+
+ let evaluator_for_require_json = evaluator.clone();
+ env.set(
+ "require_json",
+ Value::Builtin(Arc::new(move |args, _| {
+ let evaluator = evaluator_for_require_json.clone();
+ Box::pin(async move {
+ let schema = args.get(0).cloned().ok_or_else(|| {
+ RelayError::Type("require_json(schema) missing schema".into())
+ })?;
+ let request = {
+ let env = evaluator.env.lock().await;
+ env.get("request").ok()
+ };
+ let data = match request {
+ Some(Value::Dict(req)) => match req.get("json").cloned() {
+ Some(v) => v,
+ None => {
+ return Err(RelayError::Type(
+ "require_json(schema) missing JSON body".into(),
+ ))
+ }
+ },
+ _ => {
+ return Err(RelayError::Type(
+ "require_json(schema) can only be used in a web handler".into(),
+ ))
+ }
+ };
+ validate_data_with_schema(data, schema, "require_json(schema)")
+ })
+ })),
+ );
+
// sleep(ms, value=None) -> Deferred
// Non-blocking scheduler primitive:
// - returns immediately with a Deferred
@@ -2833,6 +3236,71 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
})),
);
+ let evaluator_for_http_error = evaluator.clone();
+ env.set(
+ "HTTPError",
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ let evaluator = evaluator_for_http_error.clone();
+ Box::pin(async move {
+ let status = kwargs
+ .iter()
+ .find(|(k, _)| k == "status")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(0).cloned())
+ .unwrap_or(Value::Int(500));
+ let code = kwargs
+ .iter()
+ .find(|(k, _)| k == "code")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::Str("internal_error".into()));
+ let message = kwargs
+ .iter()
+ .find(|(k, _)| k == "message")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(2).cloned())
+ .unwrap_or(Value::Str("Internal server error".into()));
+ let details = kwargs
+ .iter()
+ .find(|(k, _)| k == "details")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(3).cloned())
+ .unwrap_or(Value::None);
+
+ let status = match status {
+ Value::Int(n) => n as u16,
+ Value::Float(f) => f as u16,
+ Value::Str(s) => s.parse::<u16>().unwrap_or(500),
+ _ => 500,
+ };
+
+ let details = match details {
+ Value::None => None,
+ other => Some(value_to_json(&other)),
+ };
+
+ let request_id = {
+ let env = evaluator.env.lock().await;
+ match env.get("request").ok() {
+ Some(Value::Dict(req)) => match req.get("request_id") {
+ Some(Value::Str(s)) => Some(s.clone()),
+ _ => None,
+ },
+ _ => None,
+ }
+ };
+
+ Ok(Value::Response(error_response(
+ status,
+ &code.repr(),
+ &message.repr(),
+ details,
+ request_id,
+ )))
+ })
+ })),
+ );
+
// Web framework
env.set(
"WebServer",
@@ -2855,6 +3323,75 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
})),
);
+ env.set(
+ "auth_hash_password",
+ Value::Builtin(Arc::new(|args, _| {
+ Box::pin(async move {
+ let password = expect_str(&args, 0, "auth_hash_password(password)")?;
+ let salt = SaltString::generate(&mut OsRng);
+ let hash = Argon2::default()
+ .hash_password(password.as_bytes(), &salt)
+ .map_err(|e| RelayError::Runtime(e.to_string()))?
+ .to_string();
+ Ok(Value::Str(hash))
+ })
+ })),
+ );
+
+ env.set(
+ "auth_verify_password",
+ Value::Builtin(Arc::new(|args, _| {
+ Box::pin(async move {
+ let password = expect_str(&args, 0, "auth_verify_password(password, hash)")?;
+ let hash = expect_str(&args, 1, "auth_verify_password(password, hash)")?;
+ let parsed = match PasswordHash::new(&hash) {
+ Ok(h) => h,
+ Err(_) => return Ok(Value::Bool(false)),
+ };
+ Ok(Value::Bool(
+ Argon2::default()
+ .verify_password(password.as_bytes(), &parsed)
+ .is_ok(),
+ ))
+ })
+ })),
+ );
+
+ let evaluator_for_auth_store = evaluator.clone();
+ env.set(
+ "AuthStore",
+ Value::Builtin(Arc::new(move |args, _| {
+ let evaluator = evaluator_for_auth_store.clone();
+ Box::pin(async move {
+ if args.is_empty() {
+ return Ok(Value::Obj(Object::AuthStore(AuthStoreHandle::memory())));
+ }
+
+ let load_fn = match args.get(0).cloned() {
+ Some(Value::Function(f)) => f.name.clone(),
+ _ => {
+ return Err(RelayError::Type(
+ "AuthStore(load_fn, save_fn) expects load_fn function".into(),
+ ))
+ }
+ };
+ let save_fn = match args.get(1).cloned() {
+ Some(Value::Function(f)) => f.name.clone(),
+ _ => {
+ return Err(RelayError::Type(
+ "AuthStore(load_fn, save_fn) expects save_fn function".into(),
+ ))
+ }
+ };
+ Ok(Value::Obj(Object::AuthStore(AuthStoreHandle::callback(
+ evaluator,
+ load_fn,
+ save_fn,
+ ))))
+ })
+ })),
+ );
+
// MongoDB client
env.set(
"Mongo",
@@ -2903,34 +3440,124 @@ fn expect_int(args: &[Value], i: usize, sig: &str) -> RResult<i64> {
}
}
-fn value_to_json(v: &Value) -> J {
+fn expect_object_like(v: Value, sig: &str) -> RResult<IndexMap<String, Value>> {
match v {
- Value::Json(j) => j.clone(),
- Value::Dict(m) => {
- let mut obj = serde_json::Map::new();
- for (k, vv) in m {
- obj.insert(k.clone(), value_to_json(vv));
+ Value::Dict(m) => Ok(m),
+ Value::Json(J::Object(obj)) => Ok(obj
+ .iter()
+ .map(|(k, vv)| (k.clone(), json_to_value(vv)))
+ .collect()),
+ _ => Err(RelayError::Type(format!("{sig} expects dict/json object"))),
+ }
+}
+
+fn parse_schema_rule(
+ field: &str,
+ spec: &Value,
+) -> RResult<(String, bool, Option<String>, Option<Value>)> {
+ let mut required = true;
+ let mut field_name = field.to_string();
+ if let Some(stripped) = field.strip_suffix('?') {
+ required = false;
+ field_name = stripped.to_string();
+ }
+
+ match spec {
+ Value::Str(ty) => Ok((field_name, required, Some(ty.clone()), None)),
+ Value::Dict(rule) => {
+ let mut expected_type = None;
+ let mut default = None;
+ if let Some(Value::Str(ty)) = rule.get("type") {
+ expected_type = Some(ty.clone());
}
- J::Object(obj)
+ if let Some(v) = rule.get("required") {
+ required = bool_from_value(v, "validate required flag")?;
+ }
+ if let Some(v) = rule.get("default") {
+ default = Some(v.clone());
+ }
+ if expected_type.is_none() && default.is_none() {
+ return Err(RelayError::Type(format!(
+ "validate(schema): rule for '{field_name}' needs 'type' or 'default'"
+ )));
+ }
+ Ok((field_name, required, expected_type, default))
}
- Value::List(vs) => J::Array(vs.iter().map(value_to_json).collect()),
- Value::Str(s) => J::String(s.clone()),
- Value::Int(n) => J::Number((*n).into()),
- Value::Float(f) => serde_json::Number::from_f64(*f)
- .map(J::Number)
- .unwrap_or(J::Null),
- Value::Bool(b) => J::Bool(*b),
- Value::None => J::Null,
- Value::Bytes(b) => J::String(format!("<bytes {}>", b.len())),
- other => J::String(other.repr()),
+ _ => Err(RelayError::Type(format!(
+ "validate(schema): invalid schema for field '{field_name}'"
+ ))),
}
}
-fn json_to_value(j: &J) -> Value {
- match j {
- J::Null => Value::None,
- J::Bool(b) => Value::Bool(*b),
- J::Number(n) => {
+fn validate_data_with_schema(data: Value, schema: Value, sig: &str) -> RResult<Value> {
+ let mut input = expect_object_like(data, sig)?;
+ let schema_map = expect_object_like(schema, sig)?;
+
+ for (raw_key, rule) in schema_map {
+ let (field, required, expected_type, default) = parse_schema_rule(&raw_key, &rule)?;
+ let existing = input.get(&field).cloned();
+
+ match (existing, expected_type, default) {
+ (Some(v), Some(expected_ty), _) => {
+ let coerced = coerce_param_type(&expected_ty, v).map_err(|e| {
+ RelayError::Type(format!("validate({field}): {e}"))
+ })?;
+ input.insert(field, coerced);
+ }
+ (Some(_), None, Some(default)) => {
+ input.insert(field, default);
+ }
+ (None, _, Some(default)) => {
+ input.insert(field, default);
+ }
+ (None, Some(_), None) => {
+ if required {
+ return Err(RelayError::Type(format!(
+ "validate({field}): missing required field"
+ )));
+ }
+ }
+ (None, None, None) if required => {
+ return Err(RelayError::Type(format!(
+ "validate({field}): missing required field"
+ )))
+ }
+ (None, None, None) => {}
+ (Some(_), None, None) => {}
+ }
+ }
+
+ Ok(Value::Dict(input))
+}
+
+fn value_to_json(v: &Value) -> J {
+ match v {
+ Value::Json(j) => j.clone(),
+ Value::Dict(m) => {
+ let mut obj = serde_json::Map::new();
+ for (k, vv) in m {
+ obj.insert(k.clone(), value_to_json(vv));
+ }
+ J::Object(obj)
+ }
+ Value::List(vs) => J::Array(vs.iter().map(value_to_json).collect()),
+ Value::Str(s) => J::String(s.clone()),
+ Value::Int(n) => J::Number((*n).into()),
+ Value::Float(f) => serde_json::Number::from_f64(*f)
+ .map(J::Number)
+ .unwrap_or(J::Null),
+ Value::Bool(b) => J::Bool(*b),
+ Value::None => J::Null,
+ Value::Bytes(b) => J::String(format!("<bytes {}>", b.len())),
+ other => J::String(other.repr()),
+ }
+}
+
+fn json_to_value(j: &J) -> Value {
+ match j {
+ J::Null => Value::None,
+ J::Bool(b) => Value::Bool(*b),
+ J::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
@@ -3026,22 +3653,77 @@ struct WebAppHandle {
#[derive(Clone)]
struct RouteHandle {
app: WebAppHandle,
+ prefix: String,
}
#[derive(Clone)]
struct WebServerHandle;
-#[derive(Default)]
struct AppState {
routes: Vec<RouteSpec>,
middlewares: Vec<String>,
static_mounts: Vec<StaticMount>,
sessions: HashMap<String, IndexMap<String, Value>>,
+ session_config: SessionConfig,
+ openapi: Option<OpenApiConfig>,
+ session_backend: SessionBackendConfig,
+}
+
+#[derive(Clone)]
+struct SessionConfig {
+ secure: Option<bool>, // None => auto (https only)
+ http_only: bool,
+ same_site: String,
+}
+
+impl Default for SessionConfig {
+ fn default() -> Self {
+ Self {
+ secure: None,
+ http_only: true,
+ same_site: "Lax".to_string(),
+ }
+ }
+}
+
+#[derive(Clone)]
+struct OpenApiConfig {
+ title: String,
+ version: String,
+}
+
+impl Default for AppState {
+ fn default() -> Self {
+ Self {
+ routes: Vec::new(),
+ middlewares: Vec::new(),
+ static_mounts: Vec::new(),
+ sessions: HashMap::new(),
+ session_config: SessionConfig::default(),
+ openapi: None,
+ session_backend: SessionBackendConfig::Memory,
+ }
+ }
+}
+
+#[derive(Clone)]
+enum SessionBackendConfig {
+ Memory,
+ Callback { load_fn: String, save_fn: String },
}
#[derive(Clone)]
struct RouteSpec {
method: String,
path: String,
fn_name: String,
+ validation: RouteValidation,
+}
+
+#[derive(Clone, Default)]
+struct RouteValidation {
+ validate_schema: Option<Value>,
+ query_schema: Option<Value>,
+ body_schema: Option<Value>,
+ json_schema: Option<Value>,
}
#[derive(Clone)]
@@ -3064,16 +3746,33 @@ impl WebAppHandle {
// Back-compat: route handle style (route = app.route(); @route.get("/"))
fn route(&self) -> RouteHandle {
- RouteHandle { app: self.clone() }
+ RouteHandle {
+ app: self.clone(),
+ prefix: String::new(),
+ }
+ }
+
+ fn group(&self, prefix: String) -> RouteHandle {
+ RouteHandle {
+ app: self.clone(),
+ prefix: normalize_group_prefix(&prefix),
+ }
}
// New spec: decorator attaches directly to the app: @app.get("/"), @app.post("/")
- fn register(&self, method: String, path: String, fn_name: String) {
+ fn register(
+ &self,
+ method: String,
+ path: String,
+ fn_name: String,
+ validation: RouteValidation,
+ ) {
let mut st = self.inner.lock().unwrap();
st.routes.push(RouteSpec {
method,
path,
fn_name,
+ validation,
});
}
@@ -3082,6 +3781,26 @@ impl WebAppHandle {
st.middlewares.push(fn_name);
}
+ fn set_session_config(&self, cfg: SessionConfig) {
+ let mut st = self.inner.lock().unwrap();
+ st.session_config = cfg;
+ }
+
+ fn session_config(&self) -> SessionConfig {
+ let st = self.inner.lock().unwrap();
+ st.session_config.clone()
+ }
+
+ fn set_openapi(&self, cfg: OpenApiConfig) {
+ let mut st = self.inner.lock().unwrap();
+ st.openapi = Some(cfg);
+ }
+
+ fn openapi_config(&self) -> Option<OpenApiConfig> {
+ let st = self.inner.lock().unwrap();
+ st.openapi.clone()
+ }
+
fn mount_static(&self, route_prefix: String, dir: String) {
let mut st = self.inner.lock().unwrap();
st.static_mounts.push(StaticMount { route_prefix, dir });
@@ -3101,14 +3820,40 @@ impl WebAppHandle {
let mut st = self.inner.lock().unwrap();
st.sessions.insert(sid, data);
}
+
+ fn set_session_backend(&self, backend: SessionBackendConfig) {
+ let mut st = self.inner.lock().unwrap();
+ st.session_backend = backend;
+ }
+
+ fn session_backend(&self) -> SessionBackendConfig {
+ let st = self.inner.lock().unwrap();
+ st.session_backend.clone()
+ }
}
impl RouteHandle {
- fn register(&self, method: String, path: String, fn_name: String) {
+ fn group(&self, prefix: String) -> RouteHandle {
+ let prefix = join_route_prefix(&self.prefix, &prefix);
+ RouteHandle {
+ app: self.app.clone(),
+ prefix,
+ }
+ }
+
+ fn register(
+ &self,
+ method: String,
+ path: String,
+ fn_name: String,
+ validation: RouteValidation,
+ ) {
+ let full_path = join_route_prefix(&self.prefix, &path);
let mut st = self.app.inner.lock().unwrap();
st.routes.push(RouteSpec {
method,
- path,
+ path: full_path,
fn_name,
+ validation,
});
}
}
@@ -3118,6 +3863,8 @@ impl RouteHandle {
struct RequestParts {
method: String,
path: String,
+ request_id: String,
+ route_validation: RouteValidation,
path_params: HashMap<String, String>,
query: HashMap<String, String>,
json: Option<J>,
@@ -3162,9 +3909,13 @@ fn get_global_callback() -> Option<HandlerCb> {
}
async fn run_app(app: WebAppHandle) -> RResult<()> {
- let (specs, static_mounts) = {
+ let (specs, static_mounts, openapi_cfg) = {
let state = app.inner.lock().unwrap();
- (state.routes.clone(), state.static_mounts.clone())
+ (
+ state.routes.clone(),
+ state.static_mounts.clone(),
+ state.openapi.clone(),
+ )
};
let mut router = Router::new();
@@ -3208,18 +3959,45 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
.route(&path_pattern, get(static_handler));
}
+ if let Some(cfg) = openapi_cfg {
+ let doc = build_openapi_doc(&specs, &cfg.title, &cfg.version);
+ router = router.route(
+ "/openapi.json",
+ get(move || {
+ let doc = doc.clone();
+ async move {
+ let response = Response {
+ status: 200,
+ content_type: "application/json".into(),
+ body: doc.to_string().into_bytes(),
+ headers: HashMap::new(),
+ set_cookies: vec![],
+ };
+ Ok::<_, (StatusCode, String)>(
+ value_to_axum_response(Value::Response(response))
+ .await
+ .into_response(),
+ )
+ }
+ }),
+ );
+ }
+
for r in specs {
let method = r.method.to_lowercase();
let path = relay_path_to_axum(&r.path);
let fn_name = r.fn_name.clone();
let route_path = r.path.clone();
+ let route_validation = r.validation.clone();
let app_handle = app.clone();
let http_fn_name = fn_name.clone();
let http_route_path = route_path.clone();
+ let http_route_validation = route_validation.clone();
let http_app_handle = app_handle.clone();
let ws_fn_name = fn_name.clone();
let ws_route_path = route_path.clone();
+ let ws_route_validation = route_validation.clone();
let ws_app_handle = app_handle.clone();
// define handlers in THIS scope so route registration can capture them
@@ -3230,6 +4008,7 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
body: Bytes| {
let fn_name = http_fn_name.clone();
let route_path = http_route_path.clone();
+ let route_validation = http_route_validation.clone();
let app_handle = http_app_handle.clone();
async move {
let mut h = HashMap::new();
@@ -3240,11 +4019,18 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
}
let cookies = parse_cookie_header(h.get("cookie").cloned());
let session_id = cookies.get("relay_sid").cloned();
+ let request_id = h
+ .get("x-request-id")
+ .cloned()
+ .filter(|v| !v.trim().is_empty())
+ .unwrap_or_else(new_request_id);
let (json_body, form_body) = parse_request_body(&headers, &body);
let req = RequestParts {
method: method.to_string(),
path: route_path,
+ request_id: request_id.clone(),
+ route_validation,
path_params: ax_path,
query: q,
json: json_body,
@@ -3262,9 +4048,16 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
)
})?;
- let v = cb(app_handle, fn_name, req)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ let v = match cb(app_handle, fn_name, req).await {
+ Ok(v) => v,
+ Err(e) => Value::Response(error_response(
+ 500,
+ "internal_error",
+ &e.to_string(),
+ None,
+ Some(request_id),
+ )),
+ };
Ok::<_, (StatusCode, String)>(value_to_axum_response(v).await.into_response())
}
@@ -3276,6 +4069,7 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
headers: HeaderMap| {
let fn_name = ws_fn_name.clone();
let route_path = ws_route_path.clone();
+ let route_validation = ws_route_validation.clone();
let app_handle = ws_app_handle.clone();
async move {
let mut h = HashMap::new();
@@ -3286,6 +4080,11 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
}
let cookies = parse_cookie_header(h.get("cookie").cloned());
let session_id = cookies.get("relay_sid").cloned();
+ let request_id = h
+ .get("x-request-id")
+ .cloned()
+ .filter(|v| !v.trim().is_empty())
+ .unwrap_or_else(new_request_id);
let response = ws.on_upgrade(move |socket| async move {
let cb = match get_global_callback() {
@@ -3299,6 +4098,8 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
let req = RequestParts {
method: "WS".to_string(),
path: route_path,
+ request_id,
+ route_validation,
path_params: ax_path,
query: q,
json: None,
@@ -3388,6 +4189,35 @@ fn parse_cookie_header(raw: Option<String>) -> HashMap<String, String> {
out
}
+fn normalize_group_prefix(prefix: &str) -> String {
+ if prefix.trim().is_empty() || prefix == "/" {
+ return String::new();
+ }
+ let mut p = prefix.trim().to_string();
+ if !p.starts_with('/') {
+ p.insert(0, '/');
+ }
+ p.trim_end_matches('/').to_string()
+}
+
+fn join_route_prefix(prefix: &str, path: &str) -> String {
+ let pfx = normalize_group_prefix(prefix);
+ let leaf = if path.is_empty() {
+ "/".to_string()
+ } else if path.starts_with('/') {
+ path.to_string()
+ } else {
+ format!("/{path}")
+ };
+ if pfx.is_empty() {
+ leaf
+ } else if leaf == "/" {
+ pfx
+ } else {
+ format!("{pfx}{leaf}")
+ }
+}
+
fn new_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
@@ -3397,6 +4227,107 @@ fn new_session_id() -> String {
format!("rsid_{now:x}")
}
+fn new_request_id() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ format!("rid_{now:x}")
+}
+
+fn request_is_https(headers: &HashMap<String, String>) -> bool {
+ headers
+ .get("x-forwarded-proto")
+ .map(|v| v.eq_ignore_ascii_case("https"))
+ .unwrap_or(false)
+}
+
+fn bool_from_value(v: &Value, sig: &str) -> RResult<bool> {
+ match v {
+ Value::Bool(b) => Ok(*b),
+ Value::Int(i) => Ok(*i != 0),
+ Value::Str(s) => match s.trim().to_ascii_lowercase().as_str() {
+ "true" | "1" | "yes" | "on" => Ok(true),
+ "false" | "0" | "no" | "off" => Ok(false),
+ _ => Err(RelayError::Type(format!("{sig} expects bool"))),
+ },
+ _ => Err(RelayError::Type(format!("{sig} expects bool"))),
+ }
+}
+
+fn build_session_cookie(
+ sid: &str,
+ session_cfg: &SessionConfig,
+ request_headers: &HashMap<String, String>,
+) -> String {
+ let mut attrs = vec![format!("relay_sid={sid}"), "Path=/".to_string()];
+ if session_cfg.http_only {
+ attrs.push("HttpOnly".to_string());
+ }
+ attrs.push(format!("SameSite={}", session_cfg.same_site));
+ let secure = session_cfg
+ .secure
+ .unwrap_or_else(|| request_is_https(request_headers));
+ if secure {
+ attrs.push("Secure".to_string());
+ }
+ attrs.join("; ")
+}
+
+fn error_response(
+ status: u16,
+ code: &str,
+ message: &str,
+ details: Option<J>,
+ request_id: Option<String>,
+) -> Response {
+ let mut error_obj = serde_json::Map::new();
+ error_obj.insert("code".into(), J::String(code.to_string()));
+ error_obj.insert("message".into(), J::String(message.to_string()));
+ if let Some(d) = details {
+ error_obj.insert("details".into(), d);
+ }
+ if let Some(rid) = request_id.clone() {
+ error_obj.insert("request_id".into(), J::String(rid));
+ }
+
+ let mut root = serde_json::Map::new();
+ root.insert("error".into(), J::Object(error_obj));
+
+ let mut headers = HashMap::new();
+ if let Some(rid) = request_id {
+ headers.insert("x-request-id".into(), rid);
+ }
+
+ Response {
+ status,
+ content_type: "application/json".into(),
+ body: J::Object(root).to_string().into_bytes(),
+ headers,
+ set_cookies: vec![],
+ }
+}
+
+fn status_to_error_code(status: u16) -> &'static str {
+ match status {
+ 400 => "bad_request",
+ 401 => "unauthorized",
+ 403 => "forbidden",
+ 404 => "not_found",
+ 405 => "method_not_allowed",
+ 409 => "conflict",
+ 422 => "validation_error",
+ 429 => "rate_limited",
+ 500 => "internal_error",
+ 502 => "bad_gateway",
+ 503 => "service_unavailable",
+ 504 => "gateway_timeout",
+ _ if (400..500).contains(&status) => "client_error",
+ _ => "server_error",
+ }
+}
+
fn relay_path_to_axum(p: &str) -> String {
// "/api/<user_id>/x" -> "/api/:user_id/x"
let mut out = String::new();
@@ -3418,6 +4349,64 @@ fn relay_path_to_axum(p: &str) -> String {
out
}
+fn relay_path_to_openapi(p: &str) -> String {
+ let mut out = String::new();
+ let bytes = p.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] as char == '<' {
+ if let Some(end) = p[i + 1..].find('>') {
+ let name = &p[i + 1..i + 1 + end];
+ out.push('{');
+ out.push_str(name);
+ out.push('}');
+ i += end + 2;
+ continue;
+ }
+ }
+ out.push(bytes[i] as char);
+ i += 1;
+ }
+ out
+}
+
+fn build_openapi_doc(specs: &[RouteSpec], title: &str, version: &str) -> J {
+ let mut paths = serde_json::Map::new();
+
+ for r in specs {
+ if r.method.eq_ignore_ascii_case("ws") {
+ continue;
+ }
+ let path = relay_path_to_openapi(&r.path);
+ let method = r.method.to_ascii_lowercase();
+ let mut op = serde_json::Map::new();
+ op.insert("operationId".into(), J::String(r.fn_name.clone()));
+ op.insert(
+ "responses".into(),
+ serde_json::json!({
+ "200": { "description": "Success" },
+ "400": { "description": "Bad Request" },
+ "500": { "description": "Internal Server Error" }
+ }),
+ );
+ let path_entry = paths
+ .entry(path)
+ .or_insert_with(|| J::Object(serde_json::Map::new()));
+ if let J::Object(method_map) = path_entry {
+ method_map.insert(method, J::Object(op));
+ }
+ }
+
+ serde_json::json!({
+ "openapi": "3.0.0",
+ "info": {
+ "title": title,
+ "version": version
+ },
+ "paths": J::Object(paths)
+ })
+}
+
fn normalize_static_route_prefix(prefix: &str) -> String {
if prefix.is_empty() || prefix == "/" {
"/".to_string()
@@ -3601,6 +4590,88 @@ async fn value_to_axum_response(v: Value) -> axum::response::Response {
struct HttpHandle {
client: reqwest::Client,
}
+
+fn parse_headers_value(v: Value, sig: &str) -> RResult<HashMap<String, String>> {
+ match v {
+ Value::None => Ok(HashMap::new()),
+ Value::Dict(m) => {
+ let mut out = HashMap::new();
+ for (k, vv) in m {
+ out.insert(k, vv.repr());
+ }
+ Ok(out)
+ }
+ Value::Json(J::Object(obj)) => {
+ let mut out = HashMap::new();
+ for (k, vv) in obj {
+ out.insert(k, vv.to_string().trim_matches('"').to_string());
+ }
+ Ok(out)
+ }
+ _ => Err(RelayError::Type(format!("{sig} headers must be dict/json object"))),
+ }
+}
+
+fn apply_http_body(req: reqwest::RequestBuilder, data: Value) -> reqwest::RequestBuilder {
+ match data {
+ Value::None => req,
+ Value::Json(j) => req.json(&j),
+ Value::Dict(m) => {
+ let mut obj = serde_json::Map::new();
+ for (k, vv) in m {
+ obj.insert(k, value_to_json(&vv));
+ }
+ req.json(&J::Object(obj))
+ }
+ Value::Str(s) => req.body(s),
+ Value::Bytes(b) => req.body(b),
+ other => req.body(other.repr()),
+ }
+}
+
+async fn execute_http_request(
+ client: reqwest::Client,
+ method: &str,
+ url: String,
+ data: Value,
+ headers: HashMap<String, String>,
+) -> RResult<Value> {
+ let mut req = match method {
+ "GET" => client.get(url),
+ "POST" => client.post(url),
+ "PUT" => client.put(url),
+ "PATCH" => client.patch(url),
+ "DELETE" => client.delete(url),
+ _ => return Err(RelayError::Runtime(format!("Unsupported HTTP method: {method}"))),
+ };
+
+ for (k, v) in headers {
+ req = req.header(&k, &v);
+ }
+ req = apply_http_body(req, data);
+
+ let resp = req
+ .send()
+ .await
+ .map_err(|e| RelayError::Runtime(e.to_string()))?;
+ let status = resp.status().as_u16();
+ let headers = resp
+ .headers()
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
+ .collect::<HashMap<_, _>>();
+ let bytes = resp
+ .bytes()
+ .await
+ .map_err(|e| RelayError::Runtime(e.to_string()))?
+ .to_vec();
+ Ok(Value::Obj(Object::HttpResponse(HttpResponseHandle {
+ status,
+ headers,
+ body: bytes,
+ })))
+}
+
impl HttpHandle {
fn new() -> Self {
Self {
@@ -3611,76 +4682,116 @@ impl HttpHandle {
// bound method: (args, kwargs) -> Deferred<HttpResponse>
let client = self.client.clone();
match name {
- "get" => Value::Builtin(Arc::new(move |args, _| {
+ "get" => Value::Builtin(Arc::new(move |args, kwargs| {
let client = client.clone();
Box::pin(async move {
let url = expect_str(&args, 0, "http.get(url)")?;
+ let headers = kwargs
+ .iter()
+ .find(|(k, _)| k == "headers")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .map(|v| parse_headers_value(v, "http.get(url, headers=...)"))
+ .transpose()?
+ .unwrap_or_default();
let d = Deferred::new(Box::pin(async move {
- let resp = client
- .get(url)
- .send()
- .await
- .map_err(|e| RelayError::Runtime(e.to_string()))?;
- let status = resp.status().as_u16();
- let headers = resp
- .headers()
- .iter()
- .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
- .collect::<HashMap<_, _>>();
- let bytes = resp
- .bytes()
- .await
- .map_err(|e| RelayError::Runtime(e.to_string()))?
- .to_vec();
- Ok(Value::Obj(Object::HttpResponse(HttpResponseHandle {
- status,
- headers,
- body: bytes,
- })))
+ execute_http_request(client, "GET", url, Value::None, headers).await
}));
Ok(Value::Deferred(Arc::new(d)))
})
})),
- "post" => Value::Builtin(Arc::new(move |args, _| {
+ "post" => Value::Builtin(Arc::new(move |args, kwargs| {
let client = client.clone();
Box::pin(async move {
let url = expect_str(&args, 0, "http.post(url, data)")?;
- let data = args.get(1).cloned().unwrap_or(Value::None);
+ let data = kwargs
+ .iter()
+ .find(|(k, _)| k == "data")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::None);
+ let headers = kwargs
+ .iter()
+ .find(|(k, _)| k == "headers")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(2).cloned())
+ .map(|v| parse_headers_value(v, "http.post(url, data, headers=...)"))
+ .transpose()?
+ .unwrap_or_default();
let d = Deferred::new(Box::pin(async move {
- let req = client.post(url);
- let req = match data {
- Value::Json(j) => req.json(&j),
- Value::Dict(m) => {
- let mut obj = serde_json::Map::new();
- for (k, vv) in m {
- obj.insert(k, J::String(vv.repr()));
- }
- req.json(&J::Object(obj))
- }
- Value::Str(s) => req.body(s),
- Value::Bytes(b) => req.body(b),
- other => req.body(other.repr()),
- };
- let resp = req
- .send()
- .await
- .map_err(|e| RelayError::Runtime(e.to_string()))?;
- let status = resp.status().as_u16();
- let headers = resp
- .headers()
- .iter()
- .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
- .collect::<HashMap<_, _>>();
- let bytes = resp
- .bytes()
- .await
- .map_err(|e| RelayError::Runtime(e.to_string()))?
- .to_vec();
- Ok(Value::Obj(Object::HttpResponse(HttpResponseHandle {
- status,
- headers,
- body: bytes,
- })))
+ execute_http_request(client, "POST", url, data, headers).await
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ })),
+ "put" => Value::Builtin(Arc::new(move |args, kwargs| {
+ let client = client.clone();
+ Box::pin(async move {
+ let url = expect_str(&args, 0, "http.put(url, data)")?;
+ let data = kwargs
+ .iter()
+ .find(|(k, _)| k == "data")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::None);
+ let headers = kwargs
+ .iter()
+ .find(|(k, _)| k == "headers")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(2).cloned())
+ .map(|v| parse_headers_value(v, "http.put(url, data, headers=...)"))
+ .transpose()?
+ .unwrap_or_default();
+ let d = Deferred::new(Box::pin(async move {
+ execute_http_request(client, "PUT", url, data, headers).await
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ })),
+ "patch" => Value::Builtin(Arc::new(move |args, kwargs| {
+ let client = client.clone();
+ Box::pin(async move {
+ let url = expect_str(&args, 0, "http.patch(url, data)")?;
+ let data = kwargs
+ .iter()
+ .find(|(k, _)| k == "data")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::None);
+ let headers = kwargs
+ .iter()
+ .find(|(k, _)| k == "headers")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(2).cloned())
+ .map(|v| parse_headers_value(v, "http.patch(url, data, headers=...)"))
+ .transpose()?
+ .unwrap_or_default();
+ let d = Deferred::new(Box::pin(async move {
+ execute_http_request(client, "PATCH", url, data, headers).await
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ })),
+ "delete" => Value::Builtin(Arc::new(move |args, kwargs| {
+ let client = client.clone();
+ Box::pin(async move {
+ let url = expect_str(&args, 0, "http.delete(url)")?;
+ let data = kwargs
+ .iter()
+ .find(|(k, _)| k == "data")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::None);
+ let headers = kwargs
+ .iter()
+ .find(|(k, _)| k == "headers")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(2).cloned())
+ .map(|v| parse_headers_value(v, "http.delete(url, headers=...)"))
+ .transpose()?
+ .unwrap_or_default();
+ let d = Deferred::new(Box::pin(async move {
+ execute_http_request(client, "DELETE", url, data, headers).await
}));
Ok(Value::Deferred(Arc::new(d)))
})
@@ -3700,9 +4811,15 @@ impl HttpResponseHandle {
fn get_member(&self, name: &str) -> Value {
match name {
"status" => Value::Int(self.status as i64),
- "text" => {
- let s = String::from_utf8_lossy(&self.body).to_string();
- Value::Str(s)
+ "headers" => Value::Dict(
+ self.headers
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
+ .collect(),
+ ),
+ "text" => {
+ let s = String::from_utf8_lossy(&self.body).to_string();
+ Value::Str(s)
}
"json" => {
let body = self.body.clone();
@@ -3721,6 +4838,153 @@ impl HttpResponseHandle {
}
}
+#[derive(Clone)]
+enum AuthStoreBackend {
+ Memory(Arc<tokio::sync::Mutex<HashMap<String, String>>>),
+ Callback {
+ load_fn: String,
+ save_fn: String,
+ evaluator: Arc<Evaluator>,
+ },
+}
+
+#[derive(Clone)]
+struct AuthStoreHandle {
+ backend: AuthStoreBackend,
+}
+
+impl AuthStoreHandle {
+ fn memory() -> Self {
+ Self {
+ backend: AuthStoreBackend::Memory(Arc::new(tokio::sync::Mutex::new(HashMap::new()))),
+ }
+ }
+
+ fn callback(evaluator: Arc<Evaluator>, load_fn: String, save_fn: String) -> Self {
+ Self {
+ backend: AuthStoreBackend::Callback {
+ load_fn,
+ save_fn,
+ evaluator,
+ },
+ }
+ }
+
+ async fn load_hash(&self, username: String) -> RResult<Option<String>> {
+ match &self.backend {
+ AuthStoreBackend::Memory(store) => Ok(store.lock().await.get(&username).cloned()),
+ AuthStoreBackend::Callback {
+ load_fn,
+ evaluator,
+ ..
+ } => {
+ let out = evaluator
+ .call_named_function(load_fn, vec![Value::Str(username)])
+ .await?;
+ match out {
+ Value::None => Ok(None),
+ Value::Str(s) => Ok(Some(s)),
+ other => Err(RelayError::Type(format!(
+ "auth load callback must return str/None, got {}",
+ value_type_name(&other)
+ ))),
+ }
+ }
+ }
+ }
+
+ async fn save_hash(&self, username: String, hash: String) -> RResult<()> {
+ match &self.backend {
+ AuthStoreBackend::Memory(store) => {
+ store.lock().await.insert(username, hash);
+ Ok(())
+ }
+ AuthStoreBackend::Callback {
+ save_fn,
+ evaluator,
+ ..
+ } => {
+ let _ = evaluator
+ .call_named_function(save_fn, vec![Value::Str(username), Value::Str(hash)])
+ .await?;
+ Ok(())
+ }
+ }
+ }
+
+ fn get_member(&self, name: &str) -> Value {
+ match name {
+ "set_hash" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username = expect_str(&args, 0, "auth_store.set_hash(username, hash)")?;
+ let hash = expect_str(&args, 1, "auth_store.set_hash(username, hash)")?;
+ store.save_hash(username, hash).await?;
+ Ok(Value::None)
+ })
+ }))
+ }
+ "get_hash" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username = expect_str(&args, 0, "auth_store.get_hash(username)")?;
+ match store.load_hash(username).await? {
+ Some(hash) => Ok(Value::Str(hash)),
+ None => Ok(Value::None),
+ }
+ })
+ }))
+ }
+ "register" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username =
+ expect_str(&args, 0, "auth_store.register(username, password)")?;
+ let password =
+ expect_str(&args, 1, "auth_store.register(username, password)")?;
+ let salt = SaltString::generate(&mut OsRng);
+ let hash = Argon2::default()
+ .hash_password(password.as_bytes(), &salt)
+ .map_err(|e| RelayError::Runtime(e.to_string()))?
+ .to_string();
+ store.save_hash(username, hash).await?;
+ Ok(Value::Bool(true))
+ })
+ }))
+ }
+ "verify" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username = expect_str(&args, 0, "auth_store.verify(username, password)")?;
+ let password = expect_str(&args, 1, "auth_store.verify(username, password)")?;
+ let Some(hash) = store.load_hash(username).await? else {
+ return Ok(Value::Bool(false));
+ };
+ let parsed = match PasswordHash::new(&hash) {
+ Ok(h) => h,
+ Err(_) => return Ok(Value::Bool(false)),
+ };
+ Ok(Value::Bool(
+ Argon2::default()
+ .verify_password(password.as_bytes(), &parsed)
+ .is_ok(),
+ ))
+ })
+ }))
+ }
+ _ => Value::None,
+ }
+ }
+}
+
// ========================= MongoDB =========================
#[derive(Clone)]
@@ -3989,6 +5253,16 @@ impl Object {
Box::pin(async move { Ok(Value::Obj(Object::Route(a.route()))) })
}))
}
+ "group" => {
+ let a = a.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let a = a.clone();
+ Box::pin(async move {
+ let prefix = expect_str(&args, 0, "app.group(prefix)")?;
+ Ok(Value::Obj(Object::Route(a.group(prefix))))
+ })
+ }))
+ }
"redirect" => Value::Builtin(Arc::new(move |args, _| {
Box::pin(async move {
let location = expect_str(&args, 0, "app.redirect(location)")?;
@@ -4048,9 +5322,129 @@ impl Object {
})
}))
}
+ "openapi" => {
+ let a = a.clone();
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ let a = a.clone();
+ Box::pin(async move {
+ let title = kwargs
+ .iter()
+ .find(|(k, _)| k == "title")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(0).cloned())
+ .unwrap_or(Value::Str("Relay API".into()))
+ .repr();
+ let version = kwargs
+ .iter()
+ .find(|(k, _)| k == "version")
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(1).cloned())
+ .unwrap_or(Value::Str("1.0.0".into()))
+ .repr();
+ a.set_openapi(OpenApiConfig { title, version });
+ Ok(Value::None)
+ })
+ }))
+ }
+ "session" => {
+ let a = a.clone();
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ let a = a.clone();
+ Box::pin(async move {
+ let mut cfg = a.session_config();
+
+ if let Some(v) = args.get(0) {
+ cfg.secure = Some(bool_from_value(v, "app.session(secure=...)")?);
+ }
+ if let Some(v) = args.get(1) {
+ cfg.same_site = v.repr();
+ }
+
+ for (k, v) in kwargs {
+ match k.as_str() {
+ "secure" => {
+ cfg.secure =
+ Some(bool_from_value(&v, "app.session(secure=...)")?)
+ }
+ "http_only" => {
+ cfg.http_only =
+ bool_from_value(&v, "app.session(http_only=...)")?
+ }
+ "same_site" => cfg.same_site = v.repr(),
+ _ => {}
+ }
+ }
+
+ a.set_session_config(cfg);
+ Ok(Value::None)
+ })
+ }))
+ }
+ "session_backend" => {
+ let a = a.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let a = a.clone();
+ Box::pin(async move {
+ let load_fn = args
+ .get(0)
+ .cloned()
+ .ok_or_else(|| {
+ RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects load_fn"
+ .into(),
+ )
+ })?;
+ let save_fn = args
+ .get(1)
+ .cloned()
+ .ok_or_else(|| {
+ RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects save_fn"
+ .into(),
+ )
+ })?;
+ let load_fn = match load_fn {
+ Value::Function(f) => f.name.clone(),
+ _ => {
+ return Err(RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects functions"
+ .into(),
+ ))
+ }
+ };
+ let save_fn = match save_fn {
+ Value::Function(f) => f.name.clone(),
+ _ => {
+ return Err(RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects functions"
+ .into(),
+ ))
+ }
+ };
+
+ a.set_session_backend(SessionBackendConfig::Callback {
+ load_fn,
+ save_fn,
+ });
+ Ok(Value::None)
+ })
+ }))
+ }
+ _ => Value::None,
+ },
+ Object::Route(r) => match name {
+ "group" => {
+ let r = r.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let r = r.clone();
+ Box::pin(async move {
+ let prefix = expect_str(&args, 0, "route.group(prefix)")?;
+ Ok(Value::Obj(Object::Route(r.group(prefix))))
+ })
+ }))
+ }
_ => Value::None,
},
- Object::Route(_r) => Value::None, // decorators handle route registration
Object::WebServer(s) => match name {
"run" => {
let s = s.clone();
@@ -4128,6 +5522,7 @@ impl Object {
},
Object::Http(h) => h.get_member(name),
Object::HttpResponse(r) => r.get_member(name),
+ Object::AuthStore(a) => a.get_member(name),
Object::MongoClient(m) => m.get_member(name),
Object::MongoDatabase(d) => d.get_member(name),
Object::MongoCollection(c) => c.get_member(name),
@@ -4174,6 +5569,8 @@ impl Evaluator {
let RequestParts {
method,
path,
+ request_id,
+ route_validation,
path_params,
query,
json,
@@ -4185,8 +5582,103 @@ impl Evaluator {
} = req;
let query_map = query.clone();
- let json_body = json.clone();
+ let mut json_body = json.clone();
let form_body = form.clone();
+ let headers_map = headers.clone();
+
+ let mut query_values: IndexMap<String, Value> = query_map
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
+ .collect();
+ let mut form_values: IndexMap<String, Value> = form
+ .iter()
+ .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
+ .collect();
+ let mut json_values: Option<IndexMap<String, Value>> = json_body.as_ref().and_then(|j| {
+ if let J::Object(obj) = j {
+ Some(
+ obj.iter()
+ .map(|(k, v)| (k.clone(), json_to_value(v)))
+ .collect::<IndexMap<_, _>>(),
+ )
+ } else {
+ None
+ }
+ });
+
+ let validation_error = |msg: String| {
+ Value::Response(error_response(
+ 400,
+ "validation_error",
+ &msg,
+ None,
+ Some(request_id.clone()),
+ ))
+ };
+
+ if let Some(schema) = route_validation.query_schema {
+ let validated = match validate_data_with_schema(
+ Value::Dict(query_values.clone()),
+ schema,
+ "decorator query validate",
+ ) {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
+ query_values = expect_object_like(validated, "decorator query validate")?;
+ }
+
+ if let Some(schema) = route_validation.body_schema {
+ let validated = match validate_data_with_schema(
+ Value::Dict(form_values.clone()),
+ schema,
+ "decorator body validate",
+ ) {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
+ form_values = expect_object_like(validated, "decorator body validate")?;
+ }
+
+ if let Some(schema) = route_validation.json_schema {
+ let Some(j) = json_body.clone() else {
+ return Ok(validation_error("Missing JSON body".to_string()));
+ };
+ let validated =
+ match validate_data_with_schema(Value::Json(j), schema, "decorator json validate") {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
+ let map = expect_object_like(validated, "decorator json validate")?;
+ json_body = Some(value_to_json(&Value::Dict(map.clone())));
+ json_values = Some(map);
+ }
+
+ if let Some(schema) = route_validation.validate_schema {
+ if let Some(j) = json_body.clone() {
+ let validated = match validate_data_with_schema(
+ Value::Json(j),
+ schema,
+ "decorator validate",
+ ) {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
+ let map = expect_object_like(validated, "decorator validate")?;
+ json_body = Some(value_to_json(&Value::Dict(map.clone())));
+ json_values = Some(map);
+ } else {
+ let validated = match validate_data_with_schema(
+ Value::Dict(form_values.clone()),
+ schema,
+ "decorator validate",
+ ) {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
+ form_values = expect_object_like(validated, "decorator validate")?;
+ }
+ }
// build arg map by param list
let mut bound: HashMap<String, Value> = HashMap::new();
@@ -4196,18 +5688,18 @@ impl Evaluator {
}
// start with Query
- for (k, v) in query {
- bound.insert(k, Value::Str(v));
+ for (k, v) in &query_values {
+ bound.insert(k.clone(), v.clone());
}
// then Body
- for (k, v) in form {
- bound.insert(k, Value::Str(v));
+ for (k, v) in &form_values {
+ bound.insert(k.clone(), v.clone());
}
- if let Some(j) = json {
+ if let Some(j) = json_body.clone() {
// JSON object keys are treated like body params for handler arg binding.
- if let J::Object(obj) = &j {
- for (k, v) in obj {
- bound.insert(k.clone(), json_to_value(v));
+ if let Some(obj_values) = &json_values {
+ for (k, v) in obj_values {
+ bound.insert(k.clone(), v.clone());
}
}
@@ -4256,14 +5748,10 @@ impl Evaluator {
let mut req_dict = IndexMap::new();
req_dict.insert("method".into(), Value::Str(method));
req_dict.insert("path".into(), Value::Str(path));
+ req_dict.insert("request_id".into(), Value::Str(request_id.clone()));
req_dict.insert(
"query".into(),
- Value::Dict(
- query_map
- .iter()
- .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
- .collect(),
- ),
+ Value::Dict(query_values.clone()),
);
req_dict.insert(
"headers".into(),
@@ -4283,23 +5771,16 @@ impl Evaluator {
.collect(),
),
);
- if let Some(j) = json_body {
+ if let Some(j) = json_body.clone() {
req_dict.insert("json".into(), Value::Json(j));
}
req_dict.insert(
"form".into(),
- Value::Dict(
- form_body
- .iter()
- .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
- .collect(),
- ),
+ Value::Dict(form_values.clone()),
);
- let existing_session = session_id
- .as_ref()
- .map(|sid| app.get_session(sid))
- .unwrap_or_default();
+ let existing_session = self.load_session_data(&app, &session_id).await?;
+ let session_cfg = app.session_config();
// run handler in new scope
{
@@ -4308,7 +5789,7 @@ impl Evaluator {
for (k, v) in &bound {
env.set(k, v.clone());
}
- env.set("request", Value::Dict(req_dict));
+ env.set("request", Value::Dict(req_dict.clone()));
env.set(
"cookies",
Value::Dict(
@@ -4324,6 +5805,7 @@ impl Evaluator {
env.set("session", Value::Dict(existing_session.clone()));
}
+ let mut middlewares = Vec::new();
for mw_name in app.middleware_names() {
let mw_value = self.env.lock().await.get(&mw_name)?;
let mw_func = match mw_value {
@@ -4334,32 +5816,33 @@ impl Evaluator {
)))
}
};
+ middlewares.push(mw_func);
+ }
- let result = self.eval_block(&mw_func.body).await?;
- if let Flow::Return(v) = result {
- if !matches!(v, Value::None) {
- let final_session = {
- let env = self.env.lock().await;
- let locals = env.snapshot_top();
- match locals.get("session") {
- Some(Value::Dict(m)) => m.clone(),
- _ => existing_session.clone(),
- }
- };
-
- {
- let mut env = self.env.lock().await;
- env.pop();
- }
-
- return Ok(apply_session_and_cookies(app, session_id, final_session, v));
+ let evaluator = Arc::new(self.clone());
+ let handler_fn = func.clone();
+ let handler: Arc<dyn Fn() -> BoxFut + Send + Sync> = Arc::new(move || {
+ let evaluator = evaluator.clone();
+ let handler_fn = handler_fn.clone();
+ Box::pin(async move {
+ match evaluator.eval_block(&handler_fn.body).await? {
+ Flow::None => Ok(Value::None),
+ Flow::Return(v) => Ok(v),
}
- }
- }
+ })
+ });
- let out = match self.eval_block(&func.body).await? {
- Flow::None => Value::None,
- Flow::Return(v) => v,
+ let chain_out = if middlewares.is_empty() {
+ (handler)().await
+ } else {
+ Arc::new(self.clone())
+ .run_middleware_chain(
+ Arc::new(middlewares),
+ 0,
+ Value::Dict(req_dict.clone()),
+ handler,
+ )
+ .await
};
let final_session = {
@@ -4376,9 +5859,11 @@ impl Evaluator {
env.pop();
}
+ let out = chain_out?;
+
if websocket.is_some() {
if let Some(sid) = session_id {
- app.put_session(sid, final_session);
+ self.persist_session_data(&app, sid, final_session).await?;
}
return Ok(Value::None);
}
@@ -4450,98 +5935,20 @@ impl Evaluator {
if !final_session.is_empty() {
let sid = session_id.unwrap_or_else(new_session_id);
- app.put_session(sid.clone(), final_session);
+ self.persist_session_data(&app, sid.clone(), final_session)
+ .await?;
response
.set_cookies
- .push(format!("relay_sid={sid}; Path=/; HttpOnly; SameSite=Lax"));
+ .push(build_session_cookie(&sid, &session_cfg, &headers_map));
}
+ response
+ .headers
+ .insert("x-request-id".into(), request_id.clone());
Ok(Value::Response(response))
}
}
-fn apply_session_and_cookies(
- app: WebAppHandle,
- session_id: Option<String>,
- final_session: IndexMap<String, Value>,
- out: Value,
-) -> Value {
- let mut response = match out {
- Value::Response(r) => r,
- Value::Json(j) => Response {
- status: 200,
- content_type: "application/json".into(),
- body: j.to_string().into_bytes(),
- headers: HashMap::new(),
- set_cookies: vec![],
- },
- Value::Dict(m) => {
- let mut obj = serde_json::Map::new();
- for (k, vv) in m {
- obj.insert(k, J::String(vv.repr()));
- }
- Response {
- status: 200,
- content_type: "application/json".into(),
- body: J::Object(obj).to_string().into_bytes(),
- headers: HashMap::new(),
- set_cookies: vec![],
- }
- }
- Value::List(vs) => {
- let arr = vs
- .into_iter()
- .map(|x| J::String(x.repr()))
- .collect::<Vec<_>>();
- Response {
- status: 200,
- content_type: "application/json".into(),
- body: J::Array(arr).to_string().into_bytes(),
- headers: HashMap::new(),
- set_cookies: vec![],
- }
- }
- Value::Bytes(b) => Response {
- status: 200,
- content_type: "application/octet-stream".into(),
- body: b,
- headers: HashMap::new(),
- set_cookies: vec![],
- },
- Value::Str(s) => {
- let ct = if s.contains("<") || s.contains("{{") {
- "text/html; charset=utf-8"
- } else {
- "text/plain; charset=utf-8"
- };
- Response {
- status: 200,
- content_type: ct.into(),
- body: s.into_bytes(),
- headers: HashMap::new(),
- set_cookies: vec![],
- }
- }
- other => Response {
- status: 200,
- content_type: "text/plain; charset=utf-8".into(),
- body: other.repr().into_bytes(),
- headers: HashMap::new(),
- set_cookies: vec![],
- },
- };
-
- if !final_session.is_empty() {
- let sid = session_id.unwrap_or_else(new_session_id);
- app.put_session(sid.clone(), final_session);
- response
- .set_cookies
- .push(format!("relay_sid={sid}; Path=/; HttpOnly; SameSite=Lax"));
- }
-
- Value::Response(response)
-}
-
// ========================= Main =========================
#[tokio::main]
@@ -4714,9 +6121,62 @@ mod tests {
}
#[tokio::test]
- async fn web_handler_prefers_form_body_over_query() {
- let src = r#"fn submit(content)
- return content
+ async fn function_type_hints_require_exact_runtime_types() {
+ let src = r#"fn add(a: int, b: int)
+ return a + b
+
+add("5", "10")
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+
+ let err = match evaluator.eval_program(&program).await {
+ Ok(v) => panic!(
+ "typed function should reject string args for int params, got {}",
+ v.repr()
+ ),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(err, RelayError::Type(ref msg) if msg.contains("Expected int, got str")),
+ "expected strict int validation error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn function_type_hints_reject_unknown_types() {
+ let src = r#"fn greet(name: UserId)
+ return name
+
+greet("ada")
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+
+ let err = match evaluator.eval_program(&program).await {
+ Ok(v) => panic!("unknown type hints should fail fast, got {}", v.repr()),
+ Err(e) => e,
+ };
+ assert!(
+ matches!(err, RelayError::Type(ref msg) if msg.contains("Unknown type hint: UserId")),
+ "expected unknown type hint error, got: {err}"
+ );
+ }
+
+ #[tokio::test]
+ async fn web_handler_prefers_form_body_over_query() {
+ let src = r#"fn submit(content)
+ return content
"#;
let program = parse_src(src).expect("program should parse");
let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
@@ -4742,6 +6202,8 @@ mod tests {
RequestParts {
method: "POST".to_string(),
path: "/".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
path_params: HashMap::new(),
query,
json: None,
@@ -4764,6 +6226,57 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn web_handler_type_hints_still_coerce_query_values() {
+ let src = r#"fn search(limit: int)
+ return str(limit)
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let mut query = HashMap::new();
+ query.insert("limit".to_string(), "10".to_string());
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "search",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/search".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query,
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let text = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(text, "10");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
#[tokio::test]
async fn web_handler_binds_json_object_keys_to_args() {
let src = r#"fn submit(name, age: int = 0)
@@ -4791,6 +6304,8 @@ mod tests {
RequestParts {
method: "POST".to_string(),
path: "/".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
path_params: HashMap::new(),
query,
json: Some(serde_json::json!({
@@ -4825,6 +6340,10 @@ mod tests {
fn from_form()
payload = get_body()
return payload["name"]
+
+fn from_query()
+ payload = get_query()
+ return payload["name"]
"#;
let program = parse_src(src).expect("program should parse");
let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
@@ -4845,6 +6364,8 @@ fn from_form()
RequestParts {
method: "POST".to_string(),
path: "/".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
path_params: HashMap::new(),
query: HashMap::new(),
json: Some(serde_json::json!({
@@ -4877,6 +6398,8 @@ fn from_form()
RequestParts {
method: "POST".to_string(),
path: "/".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
path_params: HashMap::new(),
query: HashMap::new(),
json: None,
@@ -4897,6 +6420,827 @@ fn from_form()
}
other => panic!("expected response, got {}", other.repr()),
}
+
+ let mut query = HashMap::new();
+ query.insert("name".to_string(), "from-query-helper".to_string());
+ let out_query = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "from_query",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/".to_string(),
+ request_id: "test-rid".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query,
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("query helper handler should run");
+
+ match out_query {
+ Value::Response(resp) => {
+ let text = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(text, "from-query-helper");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn http_error_builtin_returns_structured_json_and_request_id() {
+ let src = r#"fn fail()
+ return HTTPError(400, "bad_request", "Missing field", {"field": "name"})
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "fail",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/fail".to_string(),
+ request_id: "rid-test".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ assert_eq!(resp.status, 400);
+ assert_eq!(
+ resp.headers.get("x-request-id").map(String::as_str),
+ Some("rid-test")
+ );
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ let parsed: J = serde_json::from_str(&body).expect("body should be json");
+ assert_eq!(parsed["error"]["code"], J::String("bad_request".to_string()));
+ assert_eq!(
+ parsed["error"]["message"],
+ J::String("Missing field".to_string())
+ );
+ assert_eq!(
+ parsed["error"]["request_id"],
+ J::String("rid-test".to_string())
+ );
+ assert_eq!(parsed["error"]["details"]["field"], J::String("name".to_string()));
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn web_handler_sets_request_id_header_on_success() {
+ let src = r#"fn ok()
+ return "ok"
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "ok",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/ok".to_string(),
+ request_id: "rid-success".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ assert_eq!(
+ resp.headers.get("x-request-id").map(String::as_str),
+ Some("rid-success")
+ );
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn middleware_supports_ctx_and_next_params() {
+ let src = r#"app = WebApp()
+
+fn mw(ctx, next)
+ if (ctx["path"] == "/hello")
+ session["via"] = "middleware"
+ next()
+
+app.use(mw)
+
+fn handler()
+ return session["via"]
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let out = evaluator
+ .call_web_handler(
+ app,
+ "handler",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/hello".to_string(),
+ request_id: "rid-mw".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let text = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(text, "middleware");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn middleware_next_chain_can_wrap_handler_result() {
+ let src = r#"app = WebApp()
+
+fn mw1(ctx, next)
+ session["trace"] = "a"
+ out = next()
+ return "m1(" + str(out) + ")"
+
+fn mw2(ctx, next)
+ session["trace"] = session["trace"] + "b"
+ out = next()
+ return session["trace"] + ":" + str(out)
+
+app.use(mw1)
+app.use(mw2)
+
+fn handler()
+ return "h"
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let out = evaluator
+ .call_web_handler(
+ app,
+ "handler",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/chain".to_string(),
+ request_id: "rid-mw-chain".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(body, "m1(ab:h)");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn middleware_next_result_is_used_when_not_returned_explicitly() {
+ let src = r#"app = WebApp()
+
+fn mw(ctx, next)
+ next()
+
+app.use(mw)
+
+fn handler()
+ return "ok"
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let out = evaluator
+ .call_web_handler(
+ app,
+ "handler",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/chain".to_string(),
+ request_id: "rid-mw-chain-2".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(body, "ok");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn decorator_query_schema_is_enforced_at_runtime() {
+ let src = r#"app = WebApp()
+schema = {"limit": "int"}
+
[email protected]("/search", query=schema)
+fn search(limit)
+ return str(limit)
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+ let route_validation = {
+ let state = app.inner.lock().unwrap();
+ state
+ .routes
+ .first()
+ .expect("route should be registered")
+ .validation
+ .clone()
+ };
+
+ let mut good_query = HashMap::new();
+ good_query.insert("limit".to_string(), "10".to_string());
+ let good = evaluator
+ .call_web_handler(
+ app.clone(),
+ "search",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/search".to_string(),
+ request_id: "rid-schema-good".to_string(),
+ route_validation: route_validation.clone(),
+ path_params: HashMap::new(),
+ query: good_query,
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("good request should run");
+ match good {
+ Value::Response(resp) => {
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(body, "10");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+
+ let mut bad_query = HashMap::new();
+ bad_query.insert("limit".to_string(), "bad".to_string());
+ let bad = evaluator
+ .call_web_handler(
+ app,
+ "search",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/search".to_string(),
+ request_id: "rid-schema-bad".to_string(),
+ route_validation,
+ path_params: HashMap::new(),
+ query: bad_query,
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("bad request should return validation response");
+ match bad {
+ Value::Response(resp) => {
+ assert_eq!(resp.status, 400);
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ let parsed: J = serde_json::from_str(&body).expect("body should be json");
+ assert_eq!(
+ parsed["error"]["code"],
+ J::String("validation_error".to_string())
+ );
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn session_backend_callbacks_can_persist_across_requests() {
+ let src = r#"session_db = {}
+app = WebApp()
+
+fn load_session(sid)
+ return session_db[sid]
+
+fn save_session(sid, data)
+ session_db[sid] = data
+
+app.session_backend(load_session, save_session)
+
+fn set_user()
+ session["user"] = "ada"
+ return "ok"
+
+fn get_user()
+ return session["user"]
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let out_set = evaluator
+ .call_web_handler(
+ app.clone(),
+ "set_user",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/set".to_string(),
+ request_id: "rid-session-a".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("set handler should run");
+
+ let sid = match out_set {
+ Value::Response(resp) => {
+ let cookie = resp
+ .set_cookies
+ .first()
+ .expect("session cookie should be set")
+ .clone();
+ cookie
+ .split(';')
+ .next()
+ .and_then(|kv| kv.split_once('='))
+ .map(|(_, v)| v.to_string())
+ .expect("session cookie should have sid")
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ };
+
+ let out_get = evaluator
+ .call_web_handler(
+ app,
+ "get_user",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/get".to_string(),
+ request_id: "rid-session-b".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: Some(sid),
+ websocket: None,
+ },
+ )
+ .await
+ .expect("get handler should run");
+
+ match out_get {
+ Value::Response(resp) => {
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(body, "ada");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn auth_hash_and_store_backends_work() {
+ let src = r#"store = AuthStore()
+h = auth_hash_password("secret")
+ok = auth_verify_password("secret", h)
+bad = auth_verify_password("wrong", h)
+
+store.register("ada", "pw")
+store_ok = store.verify("ada", "pw")
+store_bad = store.verify("ada", "nope")
+
+auth_db = {}
+fn load_user(name)
+ return auth_db[name]
+
+fn save_user(name, hash)
+ auth_db[name] = hash
+
+custom = AuthStore(load_user, save_user)
+custom.register("bob", "pw2")
+custom_ok = custom.verify("bob", "pw2")
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let env_lock = env.lock().await;
+ assert!(matches!(env_lock.get("ok").expect("ok"), Value::Bool(true)));
+ assert!(matches!(env_lock.get("bad").expect("bad"), Value::Bool(false)));
+ assert!(matches!(
+ env_lock.get("store_ok").expect("store_ok"),
+ Value::Bool(true)
+ ));
+ assert!(matches!(
+ env_lock.get("store_bad").expect("store_bad"),
+ Value::Bool(false)
+ ));
+ assert!(matches!(
+ env_lock.get("custom_ok").expect("custom_ok"),
+ Value::Bool(true)
+ ));
+ }
+
+ #[tokio::test]
+ async fn validate_helper_coerces_and_requires_fields() {
+ let src = r#"result = validate({"limit": "10"}, {"limit": "int"})
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let env_lock = env.lock().await;
+ match env_lock.get("result").expect("result should exist") {
+ Value::Dict(map) => {
+ assert!(matches!(map.get("limit"), Some(Value::Int(10))));
+ }
+ other => panic!("expected dict, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn require_query_validates_handler_input() {
+ let src = r#"fn search()
+ params = require_query({"limit": "int"})
+ return str(params["limit"])
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let mut query = HashMap::new();
+ query.insert("limit".to_string(), "25".to_string());
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "search",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/search".to_string(),
+ request_id: "rid-validate".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query,
+ json: None,
+ form: HashMap::new(),
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let body = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(body, "25");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn session_cookie_is_secure_for_https_requests() {
+ let src = r#"fn handler()
+ session["user"] = "ada"
+ return "ok"
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let mut headers = HashMap::new();
+ headers.insert("x-forwarded-proto".to_string(), "https".to_string());
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "handler",
+ RequestParts {
+ method: "GET".to_string(),
+ path: "/".to_string(),
+ request_id: "rid-cookie".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form: HashMap::new(),
+ headers,
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let cookie = resp
+ .set_cookies
+ .first()
+ .expect("session write should set cookie");
+ assert!(cookie.contains("Secure"));
+ assert!(cookie.contains("HttpOnly"));
+ assert!(cookie.contains("SameSite=Lax"));
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn route_groups_prefix_registered_paths() {
+ let src = r#"app = WebApp()
+api = app.group("/api")
+v1 = api.group("/v1")
+
[email protected]("/users/<user_id>")
+fn get_user(user_id)
+ return user_id
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let state = app.inner.lock().unwrap();
+ let route = state.routes.first().expect("one route should be registered");
+ assert_eq!(route.path, "/api/v1/users/<user_id>");
+ }
+
+ #[test]
+ fn openapi_doc_builder_emits_route_paths() {
+ let doc = build_openapi_doc(
+ &[RouteSpec {
+ method: "GET".to_string(),
+ path: "/users/<id>".to_string(),
+ fn_name: "get_user".to_string(),
+ validation: RouteValidation::default(),
+ }],
+ "Relay Test API",
+ "2026.1",
+ );
+
+ assert_eq!(doc["info"]["title"], J::String("Relay Test API".to_string()));
+ assert_eq!(doc["info"]["version"], J::String("2026.1".to_string()));
+ assert!(doc["paths"]["/users/{id}"]["get"].is_object());
+ }
+
+ #[test]
+ fn http_client_exposes_parity_methods_and_headers_member() {
+ let http = HttpHandle::new();
+ assert!(!matches!(http.get_member("put"), Value::None));
+ assert!(!matches!(http.get_member("patch"), Value::None));
+ assert!(!matches!(http.get_member("delete"), Value::None));
+
+ let mut headers = HashMap::new();
+ headers.insert("x-test".to_string(), "ok".to_string());
+ let resp = HttpResponseHandle {
+ status: 200,
+ headers,
+ body: b"ok".to_vec(),
+ };
+
+ match resp.get_member("headers") {
+ Value::Dict(h) => {
+ assert!(matches!(h.get("x-test"), Some(Value::Str(v)) if v == "ok"));
+ }
+ other => panic!("headers should be dict, got {}", other.repr()),
+ }
}
#[tokio::test]