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
- 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.
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)