patx/relay-lang

- Corrected server defaults and quick-start wording to 127.0.0.1:8080 (README.md:114, README.md:149, README.md:988, README.md:992).

Commit 4db0262 · patx · 2026-02-11T21:54:14-05:00

Changeset
4db026260403a8c3c7be180d0c767997be3574a3
Parents
8cc2eb3933f93c78cc173ce48dfea147b3e784a3

View source at this commit

- Corrected server defaults and quick-start wording to 127.0.0.1:8080 (README.md:114, README.md:149, README.md:988, README.md:992).
  - Updated install verification guidance (removed relay --version claim) (README.md:132).
  - Fixed HTTP client docs to reflect current implementation:
      - only http.get(url) and http.post(url, data=None)
      - removed undocumented put/patch/delete, request headers arg, and resp.headers (README.md:657, README.md:669).
  - Updated handler binding docs:
      - precedence is path > form body > query
      - JSON body usage via data or typed Json param
      - added request["form"] and request["json"] usage (README.md:763, README.md:785, README.md:823, README.md:830).
  - Corrected Mongo insert_many return example (inserted_ids is dict-like index -> id) (README.md:1058, README.md:1069).
  - Replaced Pastebin example with the current template-based viewer/API flow (README.md:1209), and documented required templates (README.md:1250).
  - Updated contributor/support references away from non-existent examples/ dir to test.ry + static/ (README.md:1845, README.md:1937).
  - Added identified problems to roadmap under Known Gaps (Identified in v0.1) (README.md:1905):
      - JSON key-to-scalar arg binding
      - HTTP client parity gaps
      - missing CLI flags (--help, --version)
      - missing built-in HTML escaping helper

Comments

No comments yet.

Log in to comment

Diff

diff --git a/Cargo.lock b/Cargo.lock
index fc62071..57a3220 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1526,6 +1526,7 @@ dependencies = [
  "tokio",
  "tower 0.4.13",
  "tower-http 0.5.2",
+ "url",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 41b7867..8034f35 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,3 +19,4 @@ async-recursion = "1"
 mongodb = { version = "2.8", features = ["tokio-runtime"] }
 futures = "0.3"
 minijinja = "2"
+url = "2"
diff --git a/README.md b/README.md
index e3f8ecd..7037266 100644
--- a/README.md
+++ b/README.md
@@ -106,12 +106,12 @@ app.static("/assets", "./public")
 
 @app.get("/")
 fn index()
-    return "Hello, {{ user }}"
+    return "Hello, Relay!"
 
 server.run(app)
 ```
 
-Visit `http://localhost:3000/` to see your JSON response.
+Visit `http://127.0.0.1:8080/` to see your response.
 
 
 
@@ -132,8 +132,8 @@ cd relay
 # Build and install
 cargo install --path .
 
-# Verify installation
-relay --version
+# Run a Relay program
+relay path/to/app.ry
 ```
 
 ### Development Mode
@@ -146,7 +146,7 @@ cargo run -- path/to/file.ry
 
 ### Environment Variables
 
-- `RELAY_BIND`: Override the default bind address for web servers (default: `127.0.0.1:3000`)
+- `RELAY_BIND`: Override the default bind address for web servers (default: `127.0.0.1:8080`)
 
 Example:
 ```bash
@@ -654,7 +654,7 @@ Create an HTTP client instance.
 http = Http()
 ```
 
-#### `http.get(url, headers=None)`
+#### `http.get(url)`
 **Returns:** `Deferred<Response>`
 
 Send a GET request.
@@ -666,13 +666,7 @@ print(resp.status)  // 200
 print(resp.text)    // Response body as string
 ```
 
-**With headers:**
-```relay
-headers = {"Authorization": "Bearer token123"}
-resp = http.get("https://api.example.com/protected", headers)
-```
-
-#### `http.post(url, data=None, headers=None)`
+#### `http.post(url, data=None)`
 **Returns:** `Deferred<Response>`
 
 Send a POST request with JSON body.
@@ -683,38 +677,6 @@ payload = {"name": "Ada", "email": "[email protected]"}
 resp = http.post("https://api.example.com/users", payload)
 ```
 
-#### `http.put(url, data=None, headers=None)`
-**Returns:** `Deferred<Response>`
-
-Send a PUT request with JSON body.
-
-```relay
-http = Http()
-update = {"status": "active"}
-resp = http.put("https://api.example.com/users/123", update)
-```
-
-#### `http.patch(url, data=None, headers=None)`
-**Returns:** `Deferred<Response>`
-
-Send a PATCH request with JSON body.
-
-```relay
-http = Http()
-patch = {"email": "[email protected]"}
-resp = http.patch("https://api.example.com/users/123", patch)
-```
-
-#### `http.delete(url, headers=None)`
-**Returns:** `Deferred<Response>`
-
-Send a DELETE request.
-
-```relay
-http = Http()
-resp = http.delete("https://api.example.com/users/123")
-```
-
 #### Response Object
 
 HTTP responses have the following properties:
@@ -722,14 +684,12 @@ 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()
 resp = http.get("https://api.github.com/users/octocat")
 
-print(resp.status)                    // 200
-print(resp.headers["content-type"])   // application/json
+print(resp.status)  // 200
 data = resp.json()
 print(data["login"])                  // octocat
 ```
@@ -802,7 +762,7 @@ fn get_comment(post_id, comment_id)
 
 Handler parameters are automatically bound from:
 1. **Path parameters** (highest priority)
-2. **Request body** (JSON)
+2. **Request body form fields** (`application/x-www-form-urlencoded`)
 3. **Query parameters** (lowest priority)
 
 ```relay
@@ -811,7 +771,7 @@ Handler parameters are automatically bound from:
 fn search(q: str, limit: int = 20)
     return {"query": q, "limit": limit}
 
-// POST /users with JSON body {"name": "Ada", "email": "[email protected]"}
+// POST /users with form body: name=Ada&[email protected]
 @app.post("/users")
 fn create_user(name: str, email: str)
     return {"name": name, "email": email}
@@ -822,6 +782,15 @@ fn get_user(user_id)
     return {"id": user_id}
 ```
 
+JSON bodies are available as the `data` parameter (default name) or by typing a handler param as `Json`.
+
+```relay
+// POST /events with JSON body {"type":"signup","user":"ada"}
[email protected]("/events")
+fn create_event(data: Json)
+    return {"event_type": data["type"], "user": data["user"]}
+```
+
 #### Type Hints in Handlers
 
 Use type hints to enforce parameter types and enable automatic coercion:
@@ -831,7 +800,7 @@ Use type hints to enforce parameter types and enable automatic coercion:
 fn calculate(a: int, b: int)
     return {"result": a + b}
 
-// POST /calculate with {"a": "5", "b": "10"}
+// POST /calculate with form body a=5&b=10
 // Automatically converts strings to ints: {"result": 15}
 ```
 
@@ -851,6 +820,8 @@ fn debug_request()
     print(request["method"])    // GET
     print(request["path"])      // /debug
     print(request["query"])     // Query parameters dict
+    print(request["form"])      // Form fields dict (if present)
+    print(request["json"])      // JSON body (if present)
     print(request["headers"])   // Headers dict
     print(request["cookies"])   // Cookies dict
     return "OK"
@@ -860,6 +831,7 @@ fn debug_request()
 - `method` - HTTP method (string)
 - `path` - Request path (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)
@@ -1015,11 +987,11 @@ server = WebServer()
 fn index()
     return "Hello, World!"
 
-server.run(app)  // Starts server on 127.0.0.1:3000
+server.run(app)  // Starts server on 127.0.0.1:8080
 ```
 
 **Configuration:**
-- Default bind address: `127.0.0.1:3000`
+- Default bind address: `127.0.0.1:8080`
 - Override with `RELAY_BIND` environment variable:
   ```bash
   RELAY_BIND=0.0.0.0:8080 relay server.ry
@@ -1094,7 +1066,7 @@ docs = [
     {"name": "Grace", "email": "[email protected]"}
 ]
 result = users.insert_many(docs)
-print(result["inserted_ids"])  // List of ObjectIds
+print(result["inserted_ids"])  // Dict of index -> ObjectId string
 ```
 
 #### `collection.find_one(filter)`
@@ -1243,26 +1215,42 @@ mongo = Mongo("mongodb://localhost:27017")
 db = mongo.db("pastebin")
 pastes = db.collection("pastes")
 
+fn find_paste(paste_id)
+    return pastes.find_one({"_id": paste_id})
+
 @app.get("/")
 fn index()
     return read_file("static/index.html")
 
 @app.post("/")
-fn create_paste(content: str)
+fn create_paste(content = None)
+    if (content == None)
+        return Response("Missing content", status=400)
     result = pastes.insert_one({"content": content})
     paste_id = str(result["inserted_id"])
     return app.redirect("/" + paste_id)
 
 @app.get("/<paste_id>")
 fn view_paste(paste_id)
-    paste = pastes.find_one({"_id": paste_id})
+    paste = find_paste(paste_id)
     if (paste == None)
         return Response("Not found", status=404)
-    return paste["content"]
+    return read_file("static/paste.html")
+
[email protected]("/api/paste/<paste_id>")
+fn get_paste(paste_id)
+    paste = find_paste(paste_id)
+    if (paste == None)
+        return Response("Not found", status=404)
+    return {"id": paste_id, "content": "{{ paste[\"content\"] }}"}
 
 server.run(app)
 ```
 
+Template files used by this example:
+- `static/index.html` for paste creation form
+- `static/paste.html` for `<pre><code>` viewer UI and syntax highlighting
+
 ### 4. Concurrent HTTP Requests
 
 ```relay
@@ -1857,18 +1845,18 @@ Contributions are welcome! Here's how to get started:
 ### Development Setup
 
 ```bash
-// Clone the repo
+# Clone the repo
 git clone https://github.com/yourusername/relay.git
 cd relay
 
-// Build in debug mode
+# Build in debug mode
 cargo build
 
-// Run tests
+# Run tests
 cargo test
 
-// Run with examples
-cargo run -- examples/hello.ry
+# Run the sample app
+cargo run -- test.ry
 ```
 
 ### Adding Features
@@ -1876,7 +1864,7 @@ cargo run -- examples/hello.ry
 1. **Lexer changes:** Modify the `Lexer` struct and `tokenize()` method
 2. **Parser changes:** Update the `Parser` struct and AST types
 3. **Runtime changes:** Modify the `Evaluator` and `install_stdlib()` function
-4. **Testing:** Add example programs to `examples/`
+4. **Testing:** Add runnable `.ry` scripts and corresponding docs snippets
 
 ### Coding Standards
 
@@ -1914,6 +1902,12 @@ Found a bug? [Open an issue](https://github.com/patx/relay-lang/issues) with:
 - [ ] Web framework improvements (routing groups, validation)
 - [ ] Worker processes for CPU-heavy tasks
 
+**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.
+- [ ] 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.
+
 
 ## License
 
@@ -1943,6 +1937,6 @@ SOFTWARE.
 ## Support
 
 - **Documentation:** This README
-- **Examples:** See `examples/` directory
+- **Reference app:** `test.ry` with templates under `static/`
 - **Issues:** [GitHub Issues](https://github.com/patx/relay-lang/issues)
 - **Discussions:** [GitHub Discussions](https://github.com/patx/relay-lang/discussions)
diff --git a/src/main.rs b/src/main.rs
index 5562608..3b1dae6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,12 +21,13 @@ use std::{
 };
 
 use axum::{
+    body::Bytes,
     extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
     extract::{Path, Query},
     http::{HeaderMap, Method, StatusCode},
     response::IntoResponse,
     routing::{get, post},
-    Json as AxumJson, Router,
+    Router,
 };
 
 use async_recursion::async_recursion;
@@ -34,7 +35,7 @@ use futures::{stream::TryStreamExt, SinkExt, StreamExt};
 use indexmap::IndexMap;
 use minijinja::Environment;
 use mongodb::{
-    bson::{self, Bson, Document},
+    bson::{self, oid::ObjectId, Bson, Document},
     Client as MongoClient, Collection as MongoCollection, Database as MongoDatabase,
 };
 use serde_json::Value as J;
@@ -2468,9 +2469,11 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
         "str",
         Value::Builtin(Arc::new(|args, _| {
             Box::pin(async move {
-                Ok(Value::Str(
-                    args.get(0).cloned().unwrap_or(Value::None).repr(),
-                ))
+                let out = match args.get(0).cloned().unwrap_or(Value::None) {
+                    Value::Json(J::String(s)) => s,
+                    other => other.repr(),
+                };
+                Ok(Value::Str(out))
             })
         })),
     );
@@ -2892,8 +2895,58 @@ fn value_to_document(v: Value, sig: &str) -> RResult<Document> {
     }
 }
 
+fn value_to_filter_document(v: Value, sig: &str) -> RResult<Document> {
+    let mut doc = value_to_document(v, sig)?;
+    for (k, v) in doc.iter_mut() {
+        coerce_object_id_strings(v, k == "_id");
+    }
+    Ok(doc)
+}
+
+fn coerce_object_id_strings(value: &mut Bson, id_context: bool) {
+    match value {
+        Bson::String(s) if id_context => {
+            if let Ok(oid) = ObjectId::parse_str(s.as_str()) {
+                *value = Bson::ObjectId(oid);
+            }
+        }
+        Bson::Document(doc) => {
+            for (k, v) in doc.iter_mut() {
+                coerce_object_id_strings(v, id_context || k == "_id");
+            }
+        }
+        Bson::Array(items) => {
+            for item in items.iter_mut() {
+                coerce_object_id_strings(item, id_context);
+            }
+        }
+        _ => {}
+    }
+}
+
 fn bson_to_json(bson: Bson) -> RResult<J> {
-    serde_json::to_value(bson).map_err(|e| RelayError::Runtime(e.to_string()))
+    fn convert(bson: Bson) -> RResult<J> {
+        match bson {
+            Bson::ObjectId(oid) => Ok(J::String(oid.to_hex())),
+            Bson::Document(doc) => {
+                let mut out = serde_json::Map::new();
+                for (k, v) in doc {
+                    out.insert(k, convert(v)?);
+                }
+                Ok(J::Object(out))
+            }
+            Bson::Array(items) => {
+                let mut out = Vec::with_capacity(items.len());
+                for item in items {
+                    out.push(convert(item)?);
+                }
+                Ok(J::Array(out))
+            }
+            other => serde_json::to_value(other).map_err(|e| RelayError::Runtime(e.to_string())),
+        }
+    }
+
+    convert(bson)
 }
 
 // ========================= Web runtime (Axum) =========================
@@ -3000,6 +3053,7 @@ struct RequestParts {
     path_params: HashMap<String, String>,
     query: HashMap<String, String>,
     json: Option<J>,
+    form: HashMap<String, String>,
     headers: HashMap<String, String>,
     cookies: HashMap<String, String>,
     session_id: Option<String>,
@@ -3105,7 +3159,7 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
                             Path(ax_path): Path<HashMap<String, String>>,
                             Query(q): Query<HashMap<String, String>>,
                             headers: HeaderMap,
-                            body: Option<AxumJson<J>>| {
+                            body: Bytes| {
             let fn_name = http_fn_name.clone();
             let route_path = http_route_path.clone();
             let app_handle = http_app_handle.clone();
@@ -3118,13 +3172,15 @@ 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 (json_body, form_body) = parse_request_body(&headers, &body);
 
                 let req = RequestParts {
                     method: method.to_string(),
                     path: route_path,
                     path_params: ax_path,
                     query: q,
-                    json: body.map(|b| b.0),
+                    json: json_body,
+                    form: form_body,
                     headers: h,
                     cookies,
                     session_id,
@@ -3178,6 +3234,7 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
                         path_params: ax_path,
                         query: q,
                         json: None,
+                        form: HashMap::new(),
                         headers: h,
                         cookies,
                         session_id,
@@ -3221,6 +3278,32 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
     Ok(())
 }
 
+fn parse_request_body(headers: &HeaderMap, body: &[u8]) -> (Option<J>, HashMap<String, String>) {
+    if body.is_empty() {
+        return (None, HashMap::new());
+    }
+
+    let ct = headers
+        .get(axum::http::header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("")
+        .to_ascii_lowercase();
+
+    if ct.contains("application/json") {
+        let json = serde_json::from_slice::<J>(body).ok();
+        return (json, HashMap::new());
+    }
+
+    if ct.contains("application/x-www-form-urlencoded") {
+        let form = url::form_urlencoded::parse(body)
+            .into_owned()
+            .collect::<HashMap<String, String>>();
+        return (None, form);
+    }
+
+    (None, HashMap::new())
+}
+
 fn parse_cookie_header(raw: Option<String>) -> HashMap<String, String> {
     let mut out = HashMap::new();
     if let Some(v) = raw {
@@ -3692,7 +3775,7 @@ impl MongoCollectionHandle {
                 Value::Builtin(Arc::new(move |args, _| {
                     let collection = collection.clone();
                     Box::pin(async move {
-                        let filter = value_to_document(
+                        let filter = value_to_filter_document(
                             args.get(0).cloned().unwrap_or(Value::Dict(IndexMap::new())),
                             "collection.find_one(filter)",
                         )?;
@@ -3715,7 +3798,7 @@ impl MongoCollectionHandle {
                 Value::Builtin(Arc::new(move |args, _| {
                     let collection = collection.clone();
                     Box::pin(async move {
-                        let filter = value_to_document(
+                        let filter = value_to_filter_document(
                             args.get(0).cloned().unwrap_or(Value::Dict(IndexMap::new())),
                             "collection.find(filter)",
                         )?;
@@ -3748,7 +3831,7 @@ impl MongoCollectionHandle {
                                 "collection.update_one(filter, update)".into(),
                             ));
                         }
-                        let filter = value_to_document(
+                        let filter = value_to_filter_document(
                             args[0].clone(),
                             "collection.update_one(filter, update)",
                         )?;
@@ -3784,7 +3867,7 @@ impl MongoCollectionHandle {
                 Value::Builtin(Arc::new(move |args, _| {
                     let collection = collection.clone();
                     Box::pin(async move {
-                        let filter = value_to_document(
+                        let filter = value_to_filter_document(
                             args.get(0).cloned().unwrap_or(Value::Dict(IndexMap::new())),
                             "collection.delete_one(filter)",
                         )?;
@@ -3804,7 +3887,7 @@ impl MongoCollectionHandle {
                 Value::Builtin(Arc::new(move |args, _| {
                     let collection = collection.clone();
                     Box::pin(async move {
-                        let filter = value_to_document(
+                        let filter = value_to_filter_document(
                             args.get(0).cloned().unwrap_or(Value::Dict(IndexMap::new())),
                             "collection.delete_many(filter)",
                         )?;
@@ -4026,6 +4109,7 @@ impl Evaluator {
             path_params,
             query,
             json,
+            form,
             headers,
             cookies,
             session_id,
@@ -4034,6 +4118,7 @@ impl Evaluator {
 
         let query_map = query.clone();
         let json_body = json.clone();
+        let form_body = form.clone();
 
         // build arg map by param list
         let mut bound: HashMap<String, Value> = HashMap::new();
@@ -4047,6 +4132,9 @@ impl Evaluator {
             bound.insert(k, Value::Str(v));
         }
         // then Body
+        for (k, v) in form {
+            bound.insert(k, Value::Str(v));
+        }
         if let Some(j) = json {
             // if handler has a Json param name, bind it (first param typed Json)
             let mut json_param_name = None;
@@ -4123,6 +4211,15 @@ impl Evaluator {
         if let Some(j) = json_body {
             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(),
+            ),
+        );
 
         let existing_session = session_id
             .as_ref()
@@ -4470,6 +4567,128 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn bson_object_id_is_exposed_as_plain_string() {
+        let oid = ObjectId::parse_str("698d35e0ef97187ab267d11e").expect("valid object id");
+        let json = bson_to_json(Bson::ObjectId(oid)).expect("conversion should succeed");
+        assert_eq!(json, J::String("698d35e0ef97187ab267d11e".to_string()));
+    }
+
+    #[test]
+    fn filter_document_coerces_id_strings_to_object_ids() {
+        let oid = "698d35e0ef97187ab267d11e";
+        let filter = Value::Json(serde_json::json!({
+            "_id": { "$in": [oid] },
+            "$or": [{ "_id": oid }]
+        }));
+
+        let doc =
+            value_to_filter_document(filter, "collection.find_one(filter)").expect("valid filter");
+
+        let top_id = doc
+            .get_document("_id")
+            .expect("top-level _id document")
+            .get_array("$in")
+            .expect("$in array")
+            .first()
+            .expect("first $in value");
+        assert!(matches!(top_id, Bson::ObjectId(_)));
+
+        let nested_id = doc
+            .get_array("$or")
+            .expect("$or array")
+            .first()
+            .expect("first $or clause")
+            .as_document()
+            .expect("$or clause should be a document")
+            .get("_id")
+            .expect("nested _id");
+        assert!(matches!(nested_id, Bson::ObjectId(_)));
+    }
+
+    #[test]
+    fn value_to_document_accepts_oid_extended_json() {
+        let input = Value::Json(serde_json::json!({
+            "_id": { "$oid": "698d35e0ef97187ab267d11e" }
+        }));
+
+        let doc = value_to_document(input, "test").expect("document should parse");
+        let id = doc.get("_id").expect("_id should exist");
+        assert!(
+            matches!(id, Bson::ObjectId(_)),
+            "value_to_document should coerce $oid extended JSON"
+        );
+    }
+
+    #[test]
+    fn parses_urlencoded_form_request_body() {
+        let mut headers = HeaderMap::new();
+        headers.insert(
+            axum::http::header::CONTENT_TYPE,
+            axum::http::HeaderValue::from_static("application/x-www-form-urlencoded"),
+        );
+
+        let (json, form) = parse_request_body(&headers, b"content=hello+world&tag=notes");
+        assert!(json.is_none());
+        assert_eq!(
+            form.get("content"),
+            Some(&"hello world".to_string()),
+            "content should be URL-decoded"
+        );
+        assert_eq!(form.get("tag"), Some(&"notes".to_string()));
+    }
+
+    #[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()));
+        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("content".to_string(), "from-query".to_string());
+        let mut form = HashMap::new();
+        form.insert("content".to_string(), "from-form".to_string());
+
+        let out = evaluator
+            .call_web_handler(
+                WebAppHandle::new(),
+                "submit",
+                RequestParts {
+                    method: "POST".to_string(),
+                    path: "/".to_string(),
+                    path_params: HashMap::new(),
+                    query,
+                    json: None,
+                    form,
+                    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, "from-form");
+            }
+            other => panic!("expected response, got {}", other.repr()),
+        }
+    }
+
     #[tokio::test]
     async fn supports_index_assignment_and_missing_dict_keys() {
         let src = r#"numbers = [1, 2, 3]
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..81f5455
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Relay Pastebin</title>
+  <style>
+    body {
+      margin: 0;
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+      background: #f8f8f8;
+      color: #111;
+    }
+    main {
+      max-width: 780px;
+      margin: 40px auto;
+      background: #fff;
+      border: 1px solid #ddd;
+      border-radius: 10px;
+      padding: 20px;
+    }
+    h1 {
+      margin-top: 0;
+      font-size: 1.5rem;
+    }
+    textarea {
+      width: 100%;
+      min-height: 300px;
+      box-sizing: border-box;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      font-size: 14px;
+      padding: 12px;
+      border: 1px solid #bbb;
+      border-radius: 8px;
+      resize: vertical;
+    }
+    button {
+      margin-top: 12px;
+      padding: 10px 16px;
+      border: 1px solid #222;
+      border-radius: 8px;
+      background: #222;
+      color: #fff;
+      cursor: pointer;
+    }
+    p {
+      color: #555;
+      font-size: 0.95rem;
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <h1>Create a Paste</h1>
+    <p>Submit text and you will be redirected to a unique URL.</p>
+    <form method="post" action="/">
+      <textarea name="content" placeholder="Paste content..." required></textarea>
+      <br>
+      <button type="submit">Save Paste</button>
+    </form>
+  </main>
+</body>
+</html>
diff --git a/static/paste.html b/static/paste.html
new file mode 100644
index 0000000..a9c7f68
--- /dev/null
+++ b/static/paste.html
@@ -0,0 +1,135 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Relay Paste</title>
+  <link
+    rel="stylesheet"
+    href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"
+  >
+  <style>
+    body {
+      margin: 0;
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+      background: #f5f5f7;
+      color: #111;
+    }
+    main {
+      max-width: 980px;
+      margin: 32px auto;
+      padding: 0 16px;
+    }
+    .header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+      margin-bottom: 12px;
+    }
+    .header a {
+      color: #111;
+      text-decoration: none;
+      font-weight: 600;
+    }
+    .meta {
+      color: #666;
+      font-size: 0.95rem;
+      margin: 0 0 12px;
+    }
+    .actions {
+      display: flex;
+      gap: 8px;
+      margin-bottom: 12px;
+    }
+    button {
+      border: 1px solid #bbb;
+      border-radius: 8px;
+      background: #fff;
+      color: #111;
+      padding: 8px 12px;
+      cursor: pointer;
+    }
+    pre {
+      margin: 0;
+      padding: 16px;
+      border: 1px solid #ddd;
+      border-radius: 12px;
+      background: #fff;
+      overflow-x: auto;
+      line-height: 1.45;
+    }
+    code {
+      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      font-size: 14px;
+      white-space: pre;
+    }
+    .error {
+      color: #b00020;
+      margin-top: 12px;
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <div class="header">
+      <a href="/">New Paste</a>
+      <span id="status">Loading...</span>
+    </div>
+    <p class="meta" id="paste-id"></p>
+    <div class="actions">
+      <button id="copy-btn" type="button">Copy</button>
+    </div>
+    <pre><code id="paste-code"></code></pre>
+    <p id="error" class="error"></p>
+  </main>
+
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+  <script>
+    const codeEl = document.getElementById("paste-code");
+    const statusEl = document.getElementById("status");
+    const idEl = document.getElementById("paste-id");
+    const errorEl = document.getElementById("error");
+    const copyBtn = document.getElementById("copy-btn");
+
+    const pasteId = decodeURIComponent(window.location.pathname.replace(/^\/+/, ""));
+    idEl.textContent = pasteId ? ("Paste ID: " + pasteId) : "";
+
+    copyBtn.addEventListener("click", async () => {
+      try {
+        await navigator.clipboard.writeText(codeEl.textContent || "");
+        copyBtn.textContent = "Copied";
+        setTimeout(() => {
+          copyBtn.textContent = "Copy";
+        }, 1200);
+      } catch (_err) {
+        errorEl.textContent = "Unable to copy to clipboard.";
+      }
+    });
+
+    async function loadPaste() {
+      try {
+        const response = await fetch("/api/paste/" + encodeURIComponent(pasteId));
+        if (!response.ok) {
+          statusEl.textContent = "Not found";
+          errorEl.textContent = await response.text();
+          return;
+        }
+
+        const data = await response.json();
+        const content = String(data.content || "");
+        codeEl.textContent = content;
+        if (window.hljs) {
+          window.hljs.highlightElement(codeEl);
+        }
+        statusEl.textContent = "Ready";
+      } catch (_err) {
+        statusEl.textContent = "Error";
+        errorEl.textContent = "Failed to load paste.";
+      }
+    }
+
+    loadPaste();
+  </script>
+</body>
+</html>
diff --git a/test.ry b/test.ry
index e5873a8..8037845 100644
--- a/test.ry
+++ b/test.ry
@@ -1,19 +1,40 @@
 app = WebApp()
 server = WebServer()
+mongo = Mongo("mongodb://localhost:27017")
+db = mongo.db("pastebin")
+pastes = db.collection("pastes")
 
[email protected]("/login")
-fn login(user: str = "guest")
-    session["user"] = user
-    return app.redirect("/u/" + user)
+fn find_paste(paste_id)
+    return pastes.find_one({"_id": paste_id})
 
-fn auth()
-    if (request.path == "/u/<user>" && session["user"] == None)
-        return app.redirect("/login")
[email protected]("/")
+fn index()
+    return read_file("static/index.html")
 
-app.use(auth)
[email protected]("/")
+fn create_paste(content = None)
+    if (content == None)
+        return Response("Missing content", status=400)
 
[email protected]("/u/<user>")
-fn index(user: str)
-    return "Hello, {{ user }}"
+    result = pastes.insert_one({"content": content})
+    paste_id = str(result["inserted_id"])
+    return app.redirect("/" + paste_id)
+
[email protected]("/<paste_id>")
+fn view_paste(paste_id)
+    paste = find_paste(paste_id)
+    if (paste == None)
+        return Response("Not found", status=404)
+    return read_file("static/paste.html")
+
[email protected]("/api/paste/<paste_id>")
+fn get_paste(paste_id)
+    paste = find_paste(paste_id)
+    if (paste == None)
+        return Response("Not found", status=404)
+    content = paste["content"]
+    if (content == None)
+        return Response("Not found", status=404)
+    return {"id": paste_id, "content": "{{ content }}"}
 
 server.run(app)