Rust Edge Gateway
Rust Edge Gateway is a high-performance API gateway that lets you write request handlers in Rust. Your handlers are compiled to native binaries and run as isolated worker processes, providing:
- π Native Performance - Handlers compile to optimized native code
- π Isolation - Each handler runs in its own process
- π Hot Reload - Update handlers without restarting the gateway
- π οΈ Simple SDK - Easy-to-use Request/Response API
- π¦ Service Integration - Connect to databases, Redis, and more
How It Works
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β Client ββββββΆβ Edge Gateway ββββββΆβ Your Handler β
β (Browser, β β (Routes & β β (Compiled β
β API, etc) βββββββ Manages) βββββββ Rust Binary) β
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β
βΌ
βββββββββββββββββ
β Services β
β (DB, Redis, β
β MinIO, etc) β
βββββββββββββββββ
- Gateway receives request - The gateway matches the incoming request to an endpoint
- Handler is invoked - The compiled handler binary receives the request via IPC
- Handler processes - Your code runs, optionally using injected services
- Response returned - The handler sends the response back through the gateway
Getting Started
The fastest way to get started is to:
- Access the Admin UI at
/admin/ - Create a new endpoint
- Write your handler code
- Compile and test
See the Quick Start guide for detailed instructions.
SDK Overview
Your handler code uses the rust-edge-gateway-sdk crate:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { Response::ok(json!({ "message": "Hello, World!", "path": req.path, "method": req.method, })) } handler_loop!(handle); }
The SDK provides:
- Request - Access HTTP method, path, headers, body, query params
- Response - Build HTTP responses with JSON, text, or custom content
- HandlerError - Structured error handling with HTTP status codes
- Services - Database, Redis, and other service integrations
Architecture
Rust Edge Gateway uses a worker process model:
- Main Gateway - Axum-based HTTP server handling routing
- Worker Processes - Your compiled handlers as standalone binaries
- IPC Protocol - Length-prefixed JSON over stdin/stdout
- Service Connectors - Pooled connections to backends (DB, Redis, etc.)
This architecture provides:
- Security - Handlers can't directly access the gateway's memory
- Stability - A crashed handler doesn't bring down the gateway
- Scalability - Multiple worker instances can handle concurrent requests
Quick Start
This guide will help you create your first Rust Edge Gateway endpoint in under 5 minutes.
Prerequisites
- Rust Edge Gateway running (either locally via Docker or deployed)
- Access to the Admin UI
Step 1: Access the Admin UI
Navigate to your gateway's admin interface:
- Local Development:
http://localhost:9081/admin/ - Production:
https://rust-edge-gateway.yourdomain.com/admin/
Step 2: Create an Endpoint
- Click "Create Endpoint" or the + button
- Fill in the endpoint details:
| Field | Example Value | Description |
|---|---|---|
| Name | hello-world | Unique identifier for your endpoint |
| Path | /hello | The URL path to match |
| Method | GET | HTTP method (GET, POST, PUT, DELETE, etc.) |
| Domain | * | Domain to match (or * for all) |
- Click Save
Step 3: Write Handler Code
In the code editor, replace the default code with:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { Response::ok(json!({ "message": "Hello from Rust Edge Gateway!", "path": req.path, "method": req.method, "timestamp": chrono::Utc::now().to_rfc3339(), })) } handler_loop!(handle); }
Step 4: Compile
Click the "Compile" button. The gateway will:
- Generate a Cargo project with your code
- Compile it to a native binary
- Report success or any compilation errors
You should see a success message like:
β Compiled successfully in 2.3s
Step 5: Start the Endpoint
Click "Start" to activate the endpoint. The status should change to Running.
Step 6: Test Your Endpoint
Make a request to your endpoint:
curl http://localhost:9080/hello
You should receive:
{
"message": "Hello from Rust Edge Gateway!",
"path": "/hello",
"method": "GET",
"timestamp": "2024-01-15T10:30:00.000Z"
}
What's Next?
- Your First Handler - Deeper dive into handler structure
- Handler Lifecycle - Understand compilation and execution
- Request API - Access headers, body, parameters
- Response API - Build JSON, text, and custom responses
- Examples - More code examples
Troubleshooting
Compilation Errors
Check the error message for:
- Missing dependencies (add to your handler's
usestatements) - Syntax errors (Rust compiler messages are helpful!)
- Type mismatches
Endpoint Not Responding
- Check the endpoint is in Running status
- Verify the path matches exactly (paths are case-sensitive)
- Check the method matches your request
- View endpoint logs in the admin UI
Handler Crashes
View the logs to see panic messages or error output. Common causes:
- Unwrapping
NoneorErrvalues - Stack overflow from deep recursion
- Accessing invalid JSON fields
Your First Handler
This guide explains the structure of a handler and how to work with requests and responses.
Handler Structure
Every handler follows the same pattern:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { // Your logic here Response::ok(json!({"status": "success"})) } handler_loop!(handle); }
The Prelude
The prelude module imports everything you typically need:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; // This imports: // - Request, Response types // - serde::{Deserialize, Serialize} // - serde_json::{json, Value as JsonValue} // - read_request, send_response IPC functions // - HandlerError for error handling }
The Handler Function
Your handler function receives a Request and returns a Response:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Access request data let method = &req.method; // "GET", "POST", etc. let path = &req.path; // "/users/123" // Return a response Response::ok(json!({"received": path})) } }
The Handler Loop Macro
The handler_loop! macro sets up the main function and IPC loop:
handler_loop!(handle); // This expands to: fn main() { loop { match read_request() { Ok(req) => { let response = handle(req); send_response(response).unwrap(); } Err(_) => break, } } }
Working with Requests
Accessing the Body
For POST/PUT requests, parse the JSON body:
#![allow(unused)] fn main() { #[derive(Deserialize)] struct CreateUser { name: String, email: String, } fn handle(req: Request) -> Response { // Parse JSON body let user: CreateUser = match req.json() { Ok(u) => u, Err(e) => return Response::bad_request(format!("Invalid JSON: {}", e)), }; Response::created(json!({ "id": "new-user-id", "name": user.name, "email": user.email, })) } }
Path Parameters
Extract dynamic path segments (e.g., /users/{id}):
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let user_id = req.path_param("id") .ok_or_else(|| "Missing user ID")?; Response::ok(json!({"user_id": user_id})) } }
Query Parameters
Access query string values (e.g., ?page=1&limit=10):
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let page = req.query_param("page") .map(|s| s.parse::<u32>().unwrap_or(1)) .unwrap_or(1); let limit = req.query_param("limit") .map(|s| s.parse::<u32>().unwrap_or(10)) .unwrap_or(10); Response::ok(json!({ "page": page, "limit": limit, })) } }
Headers
Access HTTP headers (case-insensitive):
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let auth = req.header("Authorization"); let content_type = req.header("Content-Type"); if auth.is_none() { return Response::json(401, json!({"error": "Unauthorized"})); } Response::ok(json!({"authenticated": true})) } }
Working with Responses
JSON Responses
The most common response type:
#![allow(unused)] fn main() { // 200 OK with JSON Response::ok(json!({"status": "success"})) // 201 Created Response::created(json!({"id": "123"})) // Custom status with JSON Response::json(418, json!({"error": "I'm a teapot"})) }
Error Responses
Built-in error response helpers:
#![allow(unused)] fn main() { Response::bad_request("Invalid input") // 400 Response::not_found() // 404 Response::internal_error("Something broke") // 500 }
Custom Headers
Add headers to any response:
#![allow(unused)] fn main() { Response::ok(json!({"data": "value"})) .with_header("X-Custom-Header", "custom-value") .with_header("Cache-Control", "max-age=3600") }
Text Responses
For non-JSON responses:
#![allow(unused)] fn main() { Response::text(200, "Hello, World!") Response::text(200, "<html><body>Hello</body></html>") .with_header("Content-Type", "text/html") }
Next Steps
- Handler Lifecycle - Compilation and process management
- Error Handling - Structured error handling
- Examples - More code examples
Handler Lifecycle
Understanding how handlers are compiled, started, and managed helps you write more reliable code.
Endpoint States
An endpoint can be in one of these states:
| State | Description |
|---|---|
| Created | Endpoint defined but code not yet compiled |
| Compiled | Code compiled successfully, ready to start |
| Running | Worker process is active and handling requests |
| Stopped | Worker process stopped, can be restarted |
| Error | Compilation or runtime error occurred |
Compilation
When you click "Compile", the gateway:
- Creates a Cargo project in the handlers directory
- Writes your code to
src/main.rs - Generates Cargo.toml with the SDK dependency
- Runs
cargo build --releaseto compile - Stores the binary for execution
Generated Project Structure
handlers/
βββ {endpoint-id}/
βββ Cargo.toml
βββ Cargo.lock
βββ src/
β βββ main.rs # Your handler code
βββ target/
βββ release/
βββ handler # Compiled binary
Cargo.toml
The generated Cargo.toml includes the SDK:
[package]
name = "handler"
version = "0.1.0"
edition = "2021"
[dependencies]
rust-edge-gateway-sdk = { path = "../../crates/rust-edge-gateway-sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
You can request additional dependencies by using them in your code - the gateway will detect common crates like chrono, uuid, regex, etc.
Worker Processes
Starting a Handler
When you start an endpoint:
- Gateway spawns the compiled binary as a child process
- IPC channels are established (stdin/stdout)
- Worker enters its request loop
- Status changes to "Running"
Request Flow
βββββββββββ ββββββββββββ ββββββββββ
β Gateway ββββββΆβ stdin ββββββΆβ Worker β
β β β (request)β β β
β βββββββ stdout βββββββ β
β β β(response)β β β
βββββββββββ ββββββββββββ ββββββββββ
The IPC protocol uses length-prefixed JSON:
- 4 bytes: message length (big-endian u32)
- N bytes: JSON payload
Worker Loop
Your handler runs in a loop:
#![allow(unused)] fn main() { loop { // 1. Read request from stdin let request = read_request()?; // 2. Call your handler function let response = handle(request); // 3. Write response to stdout send_response(response)?; } }
The loop exits when:
- stdin is closed (gateway stopped the worker)
- An IPC error occurs
- The process is killed
Stopping a Handler
When you stop an endpoint:
- Gateway closes the stdin pipe
- Worker's read_request() returns an error
- Worker exits cleanly
- Gateway waits for process exit
- Status changes to "Stopped"
Hot Reload
Rust Edge Gateway supports hot reloading:
- Edit code in the admin UI
- Compile the new version
- Restart the endpoint
- Old worker finishes current request
- New worker starts with updated code
No gateway restart required!
Error Handling
Compilation Errors
If compilation fails:
- Error message is captured and displayed
- Endpoint stays in previous state
- Previous binary (if any) remains available
Runtime Errors
If your handler panics:
- Gateway detects the worker exit
- Error is logged
- Worker can be restarted
- Endpoint moves to "Error" state
Graceful Error Handling
Always handle errors in your code:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { match process_request(&req) { Ok(data) => Response::ok(data), Err(e) => e.to_response(), // HandlerError -> Response } } fn process_request(req: &Request) -> Result<JsonValue, HandlerError> { let body: MyInput = req.json() .map_err(|e| HandlerError::ValidationError(e.to_string()))?; // ... process ... Ok(json!({"result": "success"})) } }
Handler Macros
The SDK provides several macros for running handler loops with different patterns.
Quick Reference
| Macro | Handler Signature | Use Case |
|---|---|---|
handler_loop! | fn(Request) -> Response | Simple sync handlers |
handler_loop_result! | fn(Request) -> Result<Response, HandlerError> | Sync with error handling |
handler_loop_async! | async fn(Request) -> Response | Async handlers |
handler_loop_async_result! | async fn(Request) -> Result<Response, HandlerError> | Async with error handling |
Sync Handlers
handler_loop!
For simple synchronous handlers that return a Response directly.
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { Response::ok(json!({"path": req.path, "method": req.method})) } handler_loop!(handle); }
handler_loop_result!
For handlers that return Result<Response, HandlerError>. Errors are automatically converted to HTTP responses.
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[derive(Deserialize)] struct CreateItem { name: String, price: f64, } fn handle(req: Request) -> Result<Response, HandlerError> { // These all use ? operator - errors become HTTP responses let auth = req.require_header("Authorization")?; let item: CreateItem = req.json()?; let id: i64 = req.require_path_param("id")?; if item.price < 0.0 { return Err(HandlerError::ValidationError("Price cannot be negative".into())); } Ok(Response::created(json!({"id": id, "item": item.name}))) } handler_loop_result!(handle); }
Async Handlers
Async handlers require the async feature to be enabled.
Cargo.toml
[dependencies]
rust-edge-gateway-sdk = { git = "https://github.com/user/rust-edge-gateway", features = ["async"] }
tokio = { version = "1", features = ["full"] }
handler_loop_async!
For async handlers that return a Response directly. A Tokio runtime is created automatically.
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; async fn handle(req: Request) -> Response { let data = fetch_from_api().await; Response::ok(data) } handler_loop_async!(handle); }
handler_loop_async_result!
For async handlers with Result-based error handling. Combines async support with automatic error conversion.
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; async fn handle(req: Request) -> Result<Response, HandlerError> { let data: CreateItem = req.json()?; // Async database call let id = database.insert(&data).await .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; // Async file upload let url = s3.upload(&data.file).await .map_err(|e| HandlerError::StorageError(e.to_string()))?; Ok(Response::created(json!({ "id": id, "file_url": url }))) } handler_loop_async_result!(handle); }
Runtime Management
Important: The async macros create a single Tokio runtime that persists across all requests. This is more efficient than creating a runtime per request.
#![allow(unused)] fn main() { // DON'T do this - creates runtime per request (inefficient): fn handle(req: Request) -> Response { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { // async work }) } // DO this - runtime is created once by the macro: async fn handle(req: Request) -> Response { // async work directly } handler_loop_async!(handle); }
Choosing the Right Macro
| Your handler needs... | Use this macro |
|---|---|
| Simple sync logic | handler_loop! |
Sync with ? operator | handler_loop_result! |
| Async operations (DB, HTTP, files) | handler_loop_async! |
Async with ? operator | handler_loop_async_result! |
Example: Complete CRUD Handler
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; async fn handle(req: Request) -> Result<Response, HandlerError> { match (req.method.as_str(), req.path.as_str()) { ("GET", "/items") => list_items().await, ("POST", "/items") => create_item(&req).await, ("GET", _) if req.path.starts_with("/items/") => get_item(&req).await, ("DELETE", _) if req.path.starts_with("/items/") => delete_item(&req).await, _ => Err(HandlerError::MethodNotAllowed("Use GET, POST, or DELETE".into())), } } async fn list_items() -> Result<Response, HandlerError> { let items = db::get_all_items().await .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; Ok(Response::ok(json!({"items": items}))) } async fn create_item(req: &Request) -> Result<Response, HandlerError> { let item: NewItem = req.json()?; let id = db::insert_item(&item).await .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; Ok(Response::created(json!({"id": id}))) } handler_loop_async_result!(handle); }
Request
The Request struct represents an incoming HTTP request.
Definition
#![allow(unused)] fn main() { pub struct Request { pub method: String, pub path: String, pub query: HashMap<String, String>, pub headers: HashMap<String, String>, pub body: Option<String>, pub params: HashMap<String, String>, pub client_ip: Option<String>, pub request_id: String, } }
Fields
| Field | Type | Description |
|---|---|---|
method | String | HTTP method: GET, POST, PUT, DELETE, PATCH, etc. |
path | String | Request path, e.g., /users/123 |
query | HashMap<String, String> | Query parameters from the URL |
headers | HashMap<String, String> | HTTP headers |
body | Option<String> | Request body (for POST, PUT, PATCH) |
params | HashMap<String, String> | Path parameters extracted from the route |
client_ip | Option<String> | Client's IP address |
request_id | String | Unique identifier for request tracing |
Methods Reference
JSON Parsing
json<T>() -> Result<T, HandlerError>
Parse the body as JSON into a typed struct. Returns HandlerError::BadRequest on parse failure.
#![allow(unused)] fn main() { #[derive(Deserialize)] struct CreateUser { name: String, email: String, } fn handle(req: Request) -> Result<Response, HandlerError> { let user: CreateUser = req.json()?; // Uses ? operator naturally Ok(Response::ok(json!({"name": user.name}))) } handler_loop_result!(handle); }
Query Parameters
query_param(key: &str) -> Option<&String>
Get a query parameter as a string reference.
#![allow(unused)] fn main() { // URL: /search?q=rust let query = req.query_param("q"); // Some(&"rust".to_string()) }
query_param_as<T: FromStr>(key: &str) -> Option<T>
Get a query parameter parsed as a specific type. Returns None if missing or can't be parsed.
#![allow(unused)] fn main() { // URL: /items?page=2&limit=10 let page: i64 = req.query_param_as("page").unwrap_or(1); let limit: usize = req.query_param_as("limit").unwrap_or(20); let active: bool = req.query_param_as("active").unwrap_or(false); }
require_query_param<T: FromStr>(key: &str) -> Result<T, HandlerError>
Get a required query parameter. Returns HandlerError::BadRequest if missing or invalid.
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let page: i64 = req.require_query_param("page")?; let limit: usize = req.require_query_param("limit")?; Ok(Response::ok(json!({"page": page, "limit": limit}))) } }
Path Parameters
path_param(key: &str) -> Option<&String>
Get a path parameter extracted from the route pattern.
#![allow(unused)] fn main() { // Endpoint path: /users/{id}/posts/{post_id} // Request: /users/123/posts/456 let user_id = req.path_param("id"); // Some(&"123".to_string()) let post_id = req.path_param("post_id"); // Some(&"456".to_string()) }
path_param_as<T: FromStr>(key: &str) -> Option<T>
Get a path parameter parsed as a specific type.
#![allow(unused)] fn main() { // Route: /users/{id} let user_id: i64 = req.path_param_as("id").unwrap_or(0); let uuid: Uuid = req.path_param_as("id").ok_or(HandlerError::BadRequest("Invalid UUID".into()))?; }
require_path_param<T: FromStr>(key: &str) -> Result<T, HandlerError>
Get a required path parameter. Returns HandlerError::BadRequest if missing or invalid.
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let user_id: i64 = req.require_path_param("id")?; Ok(Response::ok(json!({"user_id": user_id}))) } }
Headers
header(key: &str) -> Option<&String>
Get a header value (case-insensitive lookup).
#![allow(unused)] fn main() { // All of these work: let auth = req.header("Authorization"); let auth = req.header("authorization"); let auth = req.header("AUTHORIZATION"); }
require_header(key: &str) -> Result<&String, HandlerError>
Get a required header. Returns HandlerError::BadRequest if missing.
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let auth = req.require_header("Authorization")?; let api_key = req.require_header("X-API-Key")?; Ok(Response::ok(json!({"authenticated": true}))) } }
Request Inspection
is_method(method: &str) -> bool
Check if the request method matches (case-insensitive).
#![allow(unused)] fn main() { if req.is_method("POST") { // Handle POST } }
content_type() -> Option<&String>
Get the Content-Type header value.
#![allow(unused)] fn main() { if let Some(ct) = req.content_type() { eprintln!("Content-Type: {}", ct); } }
is_json() -> bool
Check if the request has a JSON content type.
#![allow(unused)] fn main() { if req.is_json() { let data: MyStruct = req.json()?; } }
is_multipart() -> bool
Check if the request has a multipart/form-data content type.
#![allow(unused)] fn main() { if req.is_multipart() { let multipart = req.multipart()?; } }
body_bytes() -> Vec<u8>
Get the raw body as bytes.
#![allow(unused)] fn main() { let raw_body = req.body_bytes(); }
Multipart Form Data
multipart() -> Result<MultipartData, HandlerError>
Parse multipart/form-data body for file uploads.
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let multipart = req.multipart()?; // Get text fields let title = multipart.require_field("title")?; let description = multipart.field("description").unwrap_or(&"".to_string()); // Get uploaded file let file = multipart.require_file("upload")?; eprintln!("Received file: {} ({} bytes)", file.filename, file.data.len()); Ok(Response::ok(json!({ "title": title, "filename": file.filename, "size": file.data.len() }))) } }
MultipartData
Fields
| Field | Type | Description |
|---|---|---|
fields | HashMap<String, String> | Text form fields |
files | HashMap<String, MultipartFile> | Uploaded files |
Methods
| Method | Returns | Description |
|---|---|---|
field(name) | Option<&String> | Get a text field |
require_field(name) | Result<&String, HandlerError> | Get required text field |
file(name) | Option<&MultipartFile> | Get an uploaded file |
require_file(name) | Result<&MultipartFile, HandlerError> | Get required file |
MultipartFile
#![allow(unused)] fn main() { pub struct MultipartFile { pub filename: String, // Original filename pub content_type: String, // MIME type pub data: Vec<u8>, // File content } }
Complete Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[derive(Deserialize)] struct UpdateProfile { name: Option<String>, bio: Option<String>, } fn handle(req: Request) -> Result<Response, HandlerError> { // Log request info eprintln!("[{}] {} {} from {:?}", req.request_id, req.method, req.path, req.client_ip); // Check authentication let token = req.require_header("Authorization")?; // Get path parameter (typed) let user_id: i64 = req.require_path_param("id")?; // Parse body let update: UpdateProfile = req.json()?; // Process request... Ok(Response::ok(json!({ "user_id": user_id, "updated": true, }))) } handler_loop_result!(handle); }
Response
The Response struct represents an outgoing HTTP response.
Quick Reference
| Method | Status | Use Case |
|---|---|---|
ok(body) | 200 | Successful GET/PUT |
created(body) | 201 | Successful POST |
accepted(body) | 202 | Async operation started |
no_content() | 204 | Successful DELETE |
bad_request(msg) | 400 | Invalid input |
unauthorized(msg) | 401 | Missing/invalid auth |
forbidden(msg) | 403 | Not authorized |
not_found() | 404 | Resource not found |
conflict(msg) | 409 | Resource conflict |
internal_error(msg) | 500 | Server error |
service_unavailable(msg) | 503 | Backend down |
Definition
#![allow(unused)] fn main() { pub struct Response { pub status: u16, pub headers: HashMap<String, String>, pub body: Option<String>, } }
Constructor Methods
new(status: u16)
Create a response with just a status code (no body).
#![allow(unused)] fn main() { Response::new(204) // 204 No Content Response::new(301).with_header("Location", "/new-path") }
ok<T: Serialize>(body: T)
Create a 200 OK response with JSON body.
#![allow(unused)] fn main() { Response::ok(json!({"message": "Success"})) Response::ok(my_struct) // If my_struct implements Serialize }
json<T: Serialize>(status: u16, body: T)
Create a JSON response with a custom status code.
#![allow(unused)] fn main() { Response::json(201, json!({"id": "new-id"})) Response::json(400, json!({"error": "Invalid input"})) }
text(status: u16, body: impl Into<String>)
Create a plain text response.
#![allow(unused)] fn main() { Response::text(200, "Hello, World!") Response::text(500, "Internal Server Error") }
html(status: u16, body: impl Into<String>)
Create an HTML response.
#![allow(unused)] fn main() { Response::html(200, "<html><body><h1>Hello!</h1></body></html>") }
binary(status: u16, data: impl AsRef<[u8]>, content_type: impl Into<String>)
Create a binary response for files, images, etc.
#![allow(unused)] fn main() { // Serve an image Response::binary(200, image_bytes, "image/png") // Serve a PDF with download prompt Response::binary(200, pdf_bytes, "application/pdf") .with_header("Content-Disposition", "attachment; filename=\"report.pdf\"") // Inline image Response::binary(200, jpeg_bytes, "image/jpeg") .with_header("Content-Disposition", "inline; filename=\"photo.jpg\"") }
created<T: Serialize>(body: T)
Create a 201 Created response.
#![allow(unused)] fn main() { Response::created(json!({"id": "12345", "name": "New Resource"})) }
accepted<T: Serialize>(body: T)
Create a 202 Accepted response (for async operations).
#![allow(unused)] fn main() { Response::accepted(json!({"job_id": "abc123", "status": "processing"})) }
no_content()
Create a 204 No Content response.
#![allow(unused)] fn main() { Response::no_content() // Used for DELETE }
redirect(status: u16, location: impl Into<String>)
Create a redirect response.
#![allow(unused)] fn main() { Response::redirect(302, "https://example.com/new-location") Response::redirect(301, "/permanent-new-path") }
Error Response Helpers
bad_request(message: impl Into<String>)
400 Bad Request.
#![allow(unused)] fn main() { Response::bad_request("Missing required field: email") }
unauthorized(message: impl Into<String>)
401 Unauthorized.
#![allow(unused)] fn main() { Response::unauthorized("Invalid or expired token") }
forbidden(message: impl Into<String>)
403 Forbidden.
#![allow(unused)] fn main() { Response::forbidden("You don't have permission to access this resource") }
not_found() / not_found_msg(message)
404 Not Found.
#![allow(unused)] fn main() { Response::not_found() Response::not_found_msg("User with ID 123 not found") }
conflict(message: impl Into<String>)
409 Conflict.
#![allow(unused)] fn main() { Response::conflict("A user with this email already exists") }
internal_error(message: impl Into<String>)
500 Internal Server Error.
#![allow(unused)] fn main() { Response::internal_error("Database connection failed") }
service_unavailable(message: impl Into<String>)
503 Service Unavailable.
#![allow(unused)] fn main() { Response::service_unavailable("Database is currently unavailable") }
Builder Methods
with_header(key, value)
Add a header to the response.
#![allow(unused)] fn main() { Response::ok(json!({"data": "value"})) .with_header("Cache-Control", "max-age=3600") .with_header("X-Custom-Header", "custom-value") }
with_body(body: impl Into<String>)
Set or replace the response body.
#![allow(unused)] fn main() { Response::new(200) .with_header("Content-Type", "text/html") .with_body("<html><body>Hello!</body></html>") }
with_cors(origin: impl Into<String>)
Add CORS headers for cross-origin requests.
#![allow(unused)] fn main() { Response::ok(data).with_cors("*") Response::ok(data).with_cors("https://myapp.com") }
with_cache(max_age_seconds: u32)
Add caching headers.
#![allow(unused)] fn main() { Response::ok(data).with_cache(3600) // Cache for 1 hour Response::ok(data).with_cache(0) // No cache }
Common Patterns
RESTful API
#![allow(unused)] fn main() { // GET /items - List items Response::ok(json!({"items": items})) // GET /items/{id} - Get single item Response::ok(item) // or Response::not_found() // POST /items - Create item Response::created(json!({"id": new_id, ...item})) // PUT /items/{id} - Update item Response::ok(updated_item) // DELETE /items/{id} - Delete item Response::no_content() }
Error Handling with Result
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let user = find_user(&req)?; // Returns 404 if not found Ok(Response::ok(user)) } handler_loop_result!(handle); // Errors auto-convert to Response }
File Uploads Response
#![allow(unused)] fn main() { fn handle_upload(req: Request) -> Result<Response, HandlerError> { let multipart = req.multipart()?; let file = multipart.require_file("document")?; // Process file... let saved_id = save_to_storage(&file.data)?; Ok(Response::created(json!({ "id": saved_id, "filename": file.filename, "size": file.data.len(), "content_type": file.content_type }))) } }
Serving Files
#![allow(unused)] fn main() { fn serve_image(req: Request) -> Result<Response, HandlerError> { let id = req.require_path_param::<String>("id")?; let image_data = load_image(&id)?; Ok(Response::binary(200, image_data, "image/png") .with_cache(86400) // Cache for 1 day .with_header("Content-Disposition", format!("inline; filename=\"{}.png\"", id))) } }
Custom Content Types
#![allow(unused)] fn main() { // XML Response::new(200) .with_header("Content-Type", "application/xml") .with_body("<root><item>Value</item></root>") // CSV with download Response::new(200) .with_header("Content-Type", "text/csv") .with_header("Content-Disposition", "attachment; filename=\"data.csv\"") .with_body("id,name\n1,Alice\n2,Bob") }
CORS Preflight
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { if req.is_method("OPTIONS") { return Response::no_content() .with_cors("*") .with_header("Access-Control-Max-Age", "86400"); } // Handle actual request... Response::ok(data).with_cors("*") } }
Error Handling
The SDK provides a HandlerError enum for structured error handling with automatic HTTP status code mapping.
Quick Reference
| Variant | Status | Use Case |
|---|---|---|
BadRequest(msg) | 400 | Invalid input, malformed JSON |
ValidationError(msg) | 400 | Semantic validation failures |
Unauthorized(msg) | 401 | Missing or invalid auth |
Forbidden(msg) | 403 | Authenticated but not authorized |
NotFound / NotFoundMessage(msg) | 404 | Resource not found |
MethodNotAllowed(msg) | 405 | Wrong HTTP method |
Conflict(msg) | 409 | Resource conflict (duplicate) |
PayloadTooLarge(msg) | 413 | Request body too large |
Internal(msg) / InternalError(msg) | 500 | Server error |
DatabaseError(msg) | 500 | Database operation failed |
StorageError(msg) | 500 | Storage operation failed |
ServiceUnavailable(msg) | 503 | Backend service down |
HandlerError Definition
#![allow(unused)] fn main() { pub enum HandlerError { // 4xx Client Errors BadRequest(String), ValidationError(String), Unauthorized(String), Forbidden(String), NotFound, NotFoundMessage(String), MethodNotAllowed(String), Conflict(String), PayloadTooLarge(String), // 5xx Server Errors IpcError(String), SerializationError(serde_json::Error), DatabaseError(String), RedisError(String), StorageError(String), InternalError(String), Internal(String), ServiceUnavailable(String), } }
Key Features
Automatic Response Conversion
HandlerError implements From<HandlerError> for Response, so you can use .into():
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { match process(&req) { Ok(data) => Response::ok(data), Err(e) => e.into(), // Automatically converts to Response } } }
Use with handler_loop_result!
The handler_loop_result! macro automatically converts errors to responses:
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { let data: MyInput = req.json()?; // BadRequest on parse failure let id: i64 = req.require_path_param("id")?; // BadRequest if missing let auth = req.require_header("Authorization")?; // BadRequest if missing Ok(Response::ok(json!({"id": id, "data": data}))) } handler_loop_result!(handle); // Errors auto-convert to HTTP responses }
Methods
status_code() -> u16
Get the HTTP status code for this error:
#![allow(unused)] fn main() { let err = HandlerError::NotFound; assert_eq!(err.status_code(), 404); let err = HandlerError::BadRequest("Invalid input".into()); assert_eq!(err.status_code(), 400); }
to_response() -> Response
Convert the error to an HTTP Response:
#![allow(unused)] fn main() { let err = HandlerError::ValidationError("Invalid email".to_string()); let response = err.to_response(); // Response { status: 400, body: {"error": "Validation error: Invalid email"} } }
Usage Patterns
Clean Result-Based Handlers
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[derive(Deserialize)] struct CreateUser { email: String, name: String, } fn handle(req: Request) -> Result<Response, HandlerError> { // Parse body - returns BadRequest on failure let body: CreateUser = req.json()?; // Validate if body.email.is_empty() { return Err(HandlerError::ValidationError("Email is required".into())); } // Check authentication let token = req.require_header("Authorization")?; // Get typed path parameter let user_id: i64 = req.require_path_param("id")?; // Simulate database operation let user = find_user(user_id) .ok_or(HandlerError::NotFound)?; Ok(Response::ok(user)) } handler_loop_result!(handle); }
Converting External Errors
Map external library errors to HandlerError:
#![allow(unused)] fn main() { fn save_to_database(data: &MyData) -> Result<i64, HandlerError> { let conn = get_connection() .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; let id = conn.insert(data) .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; Ok(id) } }
Custom Error Types
Define domain-specific errors and convert to HandlerError:
#![allow(unused)] fn main() { enum AppError { UserNotFound(String), DuplicateEmail, InvalidCredentials, RateLimited, } impl From<AppError> for HandlerError { fn from(e: AppError) -> Self { match e { AppError::UserNotFound(id) => HandlerError::NotFoundMessage(format!("User {} not found", id)), AppError::DuplicateEmail => HandlerError::Conflict("Email already registered".into()), AppError::InvalidCredentials => HandlerError::Unauthorized("Invalid email or password".into()), AppError::RateLimited => HandlerError::ServiceUnavailable("Rate limit exceeded, try again later".into()), } } } fn handle(req: Request) -> Result<Response, HandlerError> { let result = business_logic(&req) .map_err(HandlerError::from)?; // Convert AppError to HandlerError Ok(Response::ok(result)) } }
Async Error Handling
Works the same with async handlers:
#![allow(unused)] fn main() { async fn handle(req: Request) -> Result<Response, HandlerError> { let data: CreateItem = req.json()?; let id = database.insert(&data).await .map_err(|e| HandlerError::DatabaseError(e.to_string()))?; let uploaded = s3.put_object(&data.file).await .map_err(|e| HandlerError::StorageError(e.to_string()))?; Ok(Response::created(json!({ "id": id, "file_url": uploaded.url }))) } handler_loop_async_result!(handle); }
Logging Errors
Always log errors for debugging (logs go to stderr, captured by gateway):
#![allow(unused)] fn main() { fn handle(req: Request) -> Result<Response, HandlerError> { match process(&req) { Ok(data) => Ok(Response::ok(data)), Err(e) => { eprintln!("[{}] Error: {} ({})", req.request_id, e, e.status_code() ); Err(e) // Will be converted to Response by handler_loop_result! } } } }
Best Practices
- Use
handler_loop_result!- Simplifies error handling with automatic conversion - Use specific error variants -
BadRequestvsValidationErrorvsUnauthorized - Always log errors - Use
eprintln!for debugging - Convert early - Map external errors to
HandlerErrorat the boundary - Include context - Error messages should help debugging
Services
Rust Edge Gateway can connect your handlers to backend services like databases, Redis, and object storage.
Overview
Services are configured in the gateway admin UI and made available to handlers. Your handler code uses typed service handles to interact with backends.
Available Service Types
| Service | Description | Use Cases |
|---|---|---|
| SQLite | Embedded SQL database | Local data, caching, simple apps |
| PostgreSQL | Advanced relational database | Complex queries, transactions |
| MySQL | Popular relational database | Web applications, compatibility |
| Redis | In-memory data store | Caching, sessions, pub/sub |
| MongoDB | Document database | Flexible schemas, JSON data |
| MinIO | S3-compatible object storage | File uploads, media storage |
| Memcached | Distributed caching | High-speed key-value caching |
| FTP/SFTP | File transfer protocols | File uploads, vendor integrations |
| SMTP email sending | Notifications, alerts, reports |
Configuring Services
Via Admin UI
- Go to Services in the admin panel
- Click Create Service
- Select service type and configure connection
- Test the connection
- Bind to endpoints
Via API
curl -X POST http://localhost:9081/api/services \
-H "Content-Type: application/json" \
-d '{
"name": "Main Database",
"service_type": "postgres",
"config": {
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"username": "app_user",
"password": "secret"
}
}'
Using Services in Handlers
Services are accessed through typed handles in your handler code.
Database Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let db = DbPool { pool_id: "main-db".to_string() }; // Query let result = db.query( "SELECT id, name FROM users WHERE active = ?", &["true"] ); match result { Ok(data) => Response::ok(json!({"users": data.rows})), Err(e) => Response::internal_error(e.to_string()), } } }
Redis Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let redis = RedisPool { pool_id: "cache".to_string() }; // Try cache first if let Ok(Some(cached)) = redis.get("user:123") { return Response::ok(json!({"source": "cache", "data": cached})); } // Cache miss - fetch and store let data = fetch_from_db(); let _ = redis.setex("user:123", &data, 300); // Cache for 5 minutes Response::ok(json!({"source": "db", "data": data})) } }
Service Handles
DbPool
For SQL databases (PostgreSQL, MySQL, SQLite):
#![allow(unused)] fn main() { pub struct DbPool { pub pool_id: String, } impl DbPool { /// Execute a query, returns rows fn query(&self, sql: &str, params: &[&str]) -> Result<DbResult, HandlerError>; /// Execute a statement (INSERT, UPDATE, DELETE) fn execute(&self, sql: &str, params: &[&str]) -> Result<u64, HandlerError>; } }
RedisPool
For Redis:
#![allow(unused)] fn main() { pub struct RedisPool { pub pool_id: String, } impl RedisPool { /// Get a value fn get(&self, key: &str) -> Result<Option<String>, HandlerError>; /// Set a value fn set(&self, key: &str, value: &str) -> Result<(), HandlerError>; /// Set with expiration (seconds) fn setex(&self, key: &str, value: &str, seconds: u64) -> Result<(), HandlerError>; } }
Binding Services to Endpoints
Services must be bound to endpoints before they can be used:
- Create the service in the admin UI
- Open the endpoint configuration
- Add the service binding with a pool ID
- The pool ID is used in your handler code
This allows the same endpoint code to use different service instances in different environments (dev, staging, prod).
Next Steps
Storage Abstraction
A unified interface for storing data across different backends.
Overview
The Storage type provides a single API that works with:
- Database backends - SQLite, PostgreSQL, MySQL
- Object storage - MinIO, S3-compatible storage
- File storage - FTP, FTPS, SFTP
This allows you to write handler code once and deploy it with different backends.
Creating a Storage Instance
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; // Database storage (SQLite, PostgreSQL, MySQL) let db_storage = Storage::database("my-db-pool", "my_table"); // Object storage (MinIO, S3) let obj_storage = Storage::object_storage("my-minio-pool", "data/items"); // File storage (FTP, SFTP) let file_storage = Storage::file_storage("my-ftp-pool", "/data/items"); }
Storage Operations
Get by ID
#![allow(unused)] fn main() { let storage = Storage::database("pool", "users"); match storage.get("user-123") { Ok(Some(user)) => { // user is a JsonValue println!("Found: {}", user["name"]); } Ok(None) => { println!("Not found"); } Err(e) => { eprintln!("Error: {}", e); } } }
List Records
#![allow(unused)] fn main() { // List all let all_items = storage.list(None)?; // List with filter (interpreted based on backend) // For databases: WHERE status = ? // For files: filter by filename pattern let filtered = storage.list(Some("active"))?; }
Create Record
#![allow(unused)] fn main() { let item = json!({ "name": "Widget", "price": 29.99, "in_stock": true }); storage.create("item-001", &item)?; }
Update Record
#![allow(unused)] fn main() { let updated = json!({ "name": "Widget Pro", "price": 39.99, "in_stock": true }); storage.update("item-001", &updated)?; }
Delete Record
#![allow(unused)] fn main() { match storage.delete("item-001")? { true => println!("Deleted"), false => println!("Not found"), } }
Backend Behavior
Database (SQLite, PostgreSQL, MySQL)
- Records stored as table rows
table_namespecifies the table- Filter parameter used in
WHERE status = ? - Requires table to exist with proper schema
Object Storage (MinIO, S3)
- Records stored as JSON files
- Path:
{base_path}/{id}.json - Filter parameter passed to listing API
- Automatically creates bucket if needed
File Storage (FTP, SFTP)
- Records stored as JSON files
- Path:
{base_path}/{id}.json - Filter parameter filters file listing
- Directory must exist on server
Complete Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn get_storage() -> Storage { // Change this to switch backends: // Storage::database("pool", "items") // Storage::object_storage("minio", "items") Storage::file_storage("ftp", "/data/items") } fn handle(req: Request) -> Response { let storage = get_storage(); match req.method.as_str() { "GET" => { if let Some(id) = req.path_param("id") { // Get single item match storage.get(id) { Ok(Some(item)) => Response::ok(item), Ok(None) => Response::not_found(), Err(e) => e.to_response(), } } else { // List all items match storage.list(req.query_param("status").map(|s| s.as_str())) { Ok(items) => Response::ok(json!({"items": items})), Err(e) => e.to_response(), } } } "POST" => { let data: JsonValue = req.json().unwrap(); let id = format!("item-{}", generate_id()); match storage.create(&id, &data) { Ok(()) => Response::created(json!({"id": id})), Err(e) => e.to_response(), } } _ => Response::json(405, json!({"error": "Method not allowed"})) } } handler_loop!(handle); }
See Also
- Pet Store Demo - Complete example using Storage
- Database Services - Direct database access
- FTP Services - FTP/SFTP configuration
Database Service
Connect to SQL databases (PostgreSQL, MySQL, SQLite) from your handlers.
Configuration
PostgreSQL
{
"service_type": "postgres",
"config": {
"host": "localhost",
"port": 5432,
"database": "myapp",
"username": "app_user",
"password": "secret",
"ssl_mode": "prefer",
"pool_size": 10
}
}
MySQL
{
"service_type": "mysql",
"config": {
"host": "localhost",
"port": 3306,
"database": "myapp",
"username": "app_user",
"password": "secret",
"use_ssl": false,
"pool_size": 10
}
}
SQLite
{
"service_type": "sqlite",
"config": {
"path": "/data/app.db",
"create_if_missing": true
}
}
Usage
Basic Query
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let db = DbPool { pool_id: "main".to_string() }; let result = db.query("SELECT * FROM users WHERE active = ?", &["true"]); match result { Ok(data) => Response::ok(json!({"users": data.rows})), Err(e) => Response::internal_error(e.to_string()), } } }
Query with Parameters
Always use parameterized queries to prevent SQL injection:
#![allow(unused)] fn main() { // GOOD - parameterized db.query("SELECT * FROM users WHERE id = ?", &[&user_id]) // BAD - string concatenation (SQL injection risk!) // db.query(&format!("SELECT * FROM users WHERE id = {}", user_id), &[]) }
Insert, Update, Delete
Use execute for statements that modify data:
#![allow(unused)] fn main() { fn create_user(db: &DbPool, name: &str, email: &str) -> Result<u64, HandlerError> { db.execute( "INSERT INTO users (name, email) VALUES (?, ?)", &[name, email] ) } fn update_user(db: &DbPool, id: &str, name: &str) -> Result<u64, HandlerError> { db.execute( "UPDATE users SET name = ? WHERE id = ?", &[name, id] ) } fn delete_user(db: &DbPool, id: &str) -> Result<u64, HandlerError> { db.execute("DELETE FROM users WHERE id = ?", &[id]) } }
Working with Results
The DbResult contains rows as JSON objects:
#![allow(unused)] fn main() { let result = db.query("SELECT id, name, email FROM users", &[])?; // result.rows is Vec<HashMap<String, Value>> for row in &result.rows { let id = row.get("id"); let name = row.get("name"); println!("User: {:?} - {:?}", id, name); } // Or serialize the whole result Response::ok(json!({ "users": result.rows, "count": result.rows.len(), })) }
Typed Results
Parse rows into your own structs:
#![allow(unused)] fn main() { #[derive(Deserialize)] struct User { id: i64, name: String, email: String, } fn get_user(db: &DbPool, id: &str) -> Result<Option<User>, HandlerError> { let result = db.query("SELECT * FROM users WHERE id = ?", &[id])?; if let Some(row) = result.rows.first() { let user: User = serde_json::from_value(row.clone().into()) .map_err(|e| HandlerError::Internal(e.to_string()))?; Ok(Some(user)) } else { Ok(None) } } }
Error Handling
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let db = DbPool { pool_id: "main".to_string() }; let result = db.query("SELECT * FROM users", &[]); match result { Ok(data) => Response::ok(json!({"users": data.rows})), Err(HandlerError::DatabaseError(msg)) => { eprintln!("Database error: {}", msg); Response::internal_error("Database temporarily unavailable") } Err(HandlerError::ServiceUnavailable(msg)) => { Response::json(503, json!({"error": "Service unavailable", "retry_after": 5})) } Err(e) => e.to_response(), } } }
Best Practices
- Always use parameterized queries - Never concatenate user input into SQL
- Handle connection errors gracefully - Services may be temporarily unavailable
- Use appropriate pool sizes - Match your concurrency needs
- Keep queries simple - Complex logic is better in your handler code
- Log errors - Use
eprintln!for debugging database issues
Redis Service
Use Redis for caching, sessions, and fast key-value storage.
Configuration
{
"service_type": "redis",
"config": {
"host": "localhost",
"port": 6379,
"password": null,
"database": 0,
"use_tls": false,
"pool_size": 10
}
}
Configuration Options
| Field | Type | Default | Description |
|---|---|---|---|
host | string | required | Redis server hostname |
port | u16 | 6379 | Redis server port |
password | string | null | Redis password (optional) |
database | u8 | 0 | Redis database number (0-15) |
use_tls | bool | false | Enable TLS encryption |
pool_size | u32 | 10 | Connection pool size |
username | string | null | Username for Redis 6+ ACL |
Usage
Basic Operations
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let redis = RedisPool { pool_id: "cache".to_string() }; // Get a value match redis.get("my-key") { Ok(Some(value)) => Response::ok(json!({"value": value})), Ok(None) => Response::not_found(), Err(e) => Response::internal_error(e.to_string()), } } }
Set Values
#![allow(unused)] fn main() { // Set without expiration redis.set("key", "value")?; // Set with expiration (seconds) redis.setex("session:abc123", &session_data, 3600)?; // 1 hour redis.setex("rate:user:123", "1", 60)?; // 1 minute }
Caching Pattern
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let redis = RedisPool { pool_id: "cache".to_string() }; let db = DbPool { pool_id: "main".to_string() }; let user_id = req.path_param("id").unwrap(); let cache_key = format!("user:{}", user_id); // Try cache first if let Ok(Some(cached)) = redis.get(&cache_key) { return Response::ok(json!({ "source": "cache", "user": serde_json::from_str::<JsonValue>(&cached).unwrap() })); } // Cache miss - fetch from database let result = db.query("SELECT * FROM users WHERE id = ?", &[user_id])?; if let Some(user) = result.rows.first() { // Cache for 5 minutes let user_json = serde_json::to_string(user).unwrap(); let _ = redis.setex(&cache_key, &user_json, 300); return Response::ok(json!({ "source": "database", "user": user })); } Response::not_found() } }
Session Management
#![allow(unused)] fn main() { fn get_session(redis: &RedisPool, session_id: &str) -> Result<Option<Session>, HandlerError> { let key = format!("session:{}", session_id); match redis.get(&key)? { Some(data) => { let session: Session = serde_json::from_str(&data) .map_err(|e| HandlerError::Internal(e.to_string()))?; Ok(Some(session)) } None => Ok(None), } } fn save_session(redis: &RedisPool, session_id: &str, session: &Session) -> Result<(), HandlerError> { let key = format!("session:{}", session_id); let data = serde_json::to_string(session) .map_err(|e| HandlerError::Internal(e.to_string()))?; // Sessions expire in 24 hours redis.setex(&key, &data, 86400) } #[derive(Serialize, Deserialize)] struct Session { user_id: String, created_at: String, data: JsonValue, } }
Rate Limiting
#![allow(unused)] fn main() { fn check_rate_limit(redis: &RedisPool, client_ip: &str) -> Result<bool, HandlerError> { let key = format!("rate:{}", client_ip); match redis.get(&key)? { Some(count) => { let count: u32 = count.parse().unwrap_or(0); if count >= 100 { return Ok(false); // Rate limited } // Note: This is a simplified example // Real implementation would use INCR command redis.setex(&key, &(count + 1).to_string(), 60)?; Ok(true) } None => { redis.setex(&key, "1", 60)?; Ok(true) } } } fn handle(req: Request) -> Response { let redis = RedisPool { pool_id: "cache".to_string() }; let client_ip = req.client_ip.as_deref().unwrap_or("unknown"); match check_rate_limit(&redis, client_ip) { Ok(true) => { // Process request normally Response::ok(json!({"status": "ok"})) } Ok(false) => { Response::json(429, json!({"error": "Too many requests"})) .with_header("Retry-After", "60") } Err(e) => { // If Redis is down, allow the request (fail open) eprintln!("Rate limit check failed: {}", e); Response::ok(json!({"status": "ok"})) } } } }
Error Handling
#![allow(unused)] fn main() { match redis.get("key") { Ok(Some(value)) => { /* use value */ } Ok(None) => { /* key doesn't exist */ } Err(HandlerError::RedisError(msg)) => { eprintln!("Redis error: {}", msg); // Fallback behavior } Err(HandlerError::ServiceUnavailable(_)) => { // Redis is down - decide on fallback } Err(e) => { /* other error */ } } }
Best Practices
- Use meaningful key prefixes -
user:123,session:abc,cache:posts:1 - Always set expiration for cache keys - Prevents unbounded memory growth
- Handle Redis unavailability - Decide on fail-open vs fail-closed
- Don't store large values - Redis works best with small, fast lookups
- Use JSON for structured data - Easy to serialize/deserialize
FTP/SFTP Service
Transfer files to and from remote servers using FTP, FTPS, or SFTP.
Configuration
FTP (Unencrypted)
{
"service_type": "ftp",
"config": {
"host": "ftp.example.com",
"port": 21,
"username": "user",
"password": "secret",
"protocol": "ftp",
"base_path": "/uploads",
"passive_mode": true,
"timeout_seconds": 30
}
}
FTPS (FTP over TLS)
{
"service_type": "ftp",
"config": {
"host": "ftp.example.com",
"port": 21,
"username": "user",
"password": "secret",
"protocol": "ftps",
"passive_mode": true
}
}
SFTP (SSH File Transfer)
{
"service_type": "ftp",
"config": {
"host": "sftp.example.com",
"port": 22,
"username": "user",
"password": "secret",
"protocol": "sftp",
"base_path": "/home/user/uploads"
}
}
Or with SSH key authentication:
{
"service_type": "ftp",
"config": {
"host": "sftp.example.com",
"port": 22,
"username": "user",
"private_key_path": "/path/to/id_rsa",
"protocol": "sftp"
}
}
Configuration Options
| Field | Type | Default | Description |
|---|---|---|---|
host | string | required | FTP server hostname |
port | u16 | 21/22 | Server port (21 for FTP/FTPS, 22 for SFTP) |
username | string | required | Login username |
password | string | null | Login password |
private_key_path | string | null | Path to SSH private key (SFTP only) |
protocol | string | "ftp" | Protocol: "ftp", "ftps", or "sftp" |
base_path | string | null | Default directory on server |
passive_mode | bool | true | Use passive mode (FTP/FTPS only) |
timeout_seconds | u32 | 30 | Connection timeout |
Usage
Upload a File
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let ftp = FtpPool { pool_id: "uploads".to_string() }; // Upload content let content = req.body.as_ref().unwrap(); let result = ftp.put("/reports/daily.csv", content.as_bytes()); match result { Ok(()) => Response::ok(json!({"uploaded": true})), Err(e) => Response::internal_error(e.to_string()), } } }
Download a File
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let ftp = FtpPool { pool_id: "files".to_string() }; let filename = req.path_param("filename").unwrap(); let path = format!("/data/{}", filename); match ftp.get(&path) { Ok(content) => Response::new(200) .with_header("Content-Type", "application/octet-stream") .with_body(content), Err(e) => Response::not_found(), } } }
List Directory
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let ftp = FtpPool { pool_id: "files".to_string() }; match ftp.list("/reports") { Ok(files) => Response::ok(json!({"files": files})), Err(e) => Response::internal_error(e.to_string()), } } }
Use Cases
- File uploads - Accept user uploads and store on FTP server
- Report distribution - Upload generated reports to partner SFTP servers
- Data import - Download files from vendor FTP for processing
- Backup - Archive data to remote storage
- Legacy integration - Connect to systems that only support FTP
Security Notes
- Prefer SFTP - Uses SSH encryption, most secure option
- Use FTPS if SFTP unavailable - TLS encryption for FTP
- Avoid plain FTP - Credentials sent in cleartext
- Use SSH keys - More secure than passwords for SFTP
- Restrict base_path - Limit access to specific directories
Email (SMTP) Service
Send emails from your handlers using SMTP.
Configuration
Basic SMTP with STARTTLS
{
"service_type": "email",
"config": {
"host": "smtp.example.com",
"port": 587,
"username": "sender@example.com",
"password": "app-password",
"encryption": "starttls",
"from_address": "noreply@example.com",
"from_name": "My App",
"reply_to": "support@example.com"
}
}
Gmail SMTP
{
"service_type": "email",
"config": {
"host": "smtp.gmail.com",
"port": 587,
"username": "your-email@gmail.com",
"password": "your-app-password",
"encryption": "starttls",
"from_address": "your-email@gmail.com",
"from_name": "Your Name"
}
}
Implicit TLS (Port 465)
{
"service_type": "email",
"config": {
"host": "smtp.example.com",
"port": 465,
"username": "sender@example.com",
"password": "secret",
"encryption": "tls",
"from_address": "noreply@example.com"
}
}
Local SMTP (No Auth)
{
"service_type": "email",
"config": {
"host": "localhost",
"port": 25,
"encryption": "none",
"from_address": "app@localhost"
}
}
Configuration Options
| Field | Type | Default | Description |
|---|---|---|---|
host | string | required | SMTP server hostname |
port | u16 | 587 | SMTP port |
username | string | null | Auth username (usually email address) |
password | string | null | Auth password or app password |
encryption | string | "starttls" | Encryption: "none", "starttls", or "tls" |
from_address | string | required | Default sender email |
from_name | string | null | Default sender display name |
reply_to | string | null | Default reply-to address |
timeout_seconds | u32 | 30 | Connection timeout |
max_retries | u32 | 3 | Send retry attempts |
Usage
Send a Simple Email
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { let email = EmailPool { pool_id: "notifications".to_string() }; let result = email.send( "user@example.com", "Welcome!", "Thanks for signing up.", ); match result { Ok(()) => Response::ok(json!({"sent": true})), Err(e) => Response::internal_error(e.to_string()), } } }
Send HTML Email
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let email = EmailPool { pool_id: "notifications".to_string() }; let html = r#" <h1>Welcome!</h1> <p>Thanks for joining us.</p> <a href="https://example.com/verify">Verify your email</a> "#; let result = email.send_html( "user@example.com", "Welcome to Our App", html, ); match result { Ok(()) => Response::ok(json!({"sent": true})), Err(e) => Response::internal_error(e.to_string()), } } }
Send with Custom From
#![allow(unused)] fn main() { let result = email.send_from( "support@example.com", // From "Support Team", // From name "user@example.com", // To "Your Ticket #123", // Subject "We've received your support request...", ); }
Common Providers
| Provider | Host | Port | Encryption |
|---|---|---|---|
| Gmail | smtp.gmail.com | 587 | starttls |
| Outlook | smtp.office365.com | 587 | starttls |
| SendGrid | smtp.sendgrid.net | 587 | starttls |
| Mailgun | smtp.mailgun.org | 587 | starttls |
| Amazon SES | email-smtp.{region}.amazonaws.com | 587 | starttls |
Best Practices
- Use app passwords - Don't use your main account password
- Set up SPF/DKIM - Improve deliverability
- Handle failures - Emails can fail; log and retry
- Rate limit - Don't spam; respect provider limits
- Use templates - Consistent formatting
- Test with sandbox - Use test mode before production
Hello World
The simplest possible handler.
Code
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(_req: Request) -> Response { Response::ok(json!({ "message": "Hello, World!" })) } handler_loop!(handle); }
Endpoint Configuration
| Setting | Value |
|---|---|
| Path | /hello |
| Method | GET |
| Domain | * |
Test
curl http://localhost:9080/hello
Response
{
"message": "Hello, World!"
}
Variations
With Request Info
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { Response::ok(json!({ "message": "Hello, World!", "method": req.method, "path": req.path, "request_id": req.request_id, })) } }
Plain Text
#![allow(unused)] fn main() { fn handle(_req: Request) -> Response { Response::text(200, "Hello, World!") } }
HTML
#![allow(unused)] fn main() { fn handle(_req: Request) -> Response { Response::new(200) .with_header("Content-Type", "text/html") .with_body("<h1>Hello, World!</h1>") } }
JSON API
Build a RESTful JSON API endpoint.
Code
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[derive(Deserialize)] struct CreateItem { name: String, description: Option<String>, price: f64, } #[derive(Serialize)] struct Item { id: String, name: String, description: Option<String>, price: f64, created_at: String, } fn handle(req: Request) -> Response { // Parse request body let input: CreateItem = match req.json() { Ok(data) => data, Err(e) => return Response::bad_request(format!("Invalid JSON: {}", e)), }; // Validate if input.name.is_empty() { return Response::bad_request("Name is required"); } if input.price < 0.0 { return Response::bad_request("Price must be non-negative"); } // Create item (in real app, save to database) let item = Item { id: uuid::Uuid::new_v4().to_string(), name: input.name, description: input.description, price: input.price, created_at: chrono::Utc::now().to_rfc3339(), }; // Return 201 Created Response::created(item) } handler_loop!(handle); }
Endpoint Configuration
| Setting | Value |
|---|---|
| Path | /items |
| Method | POST |
| Domain | * |
Test
curl -X POST http://localhost:9080/items \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "description": "A useful widget", "price": 19.99}'
Response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Widget",
"description": "A useful widget",
"price": 19.99,
"created_at": "2024-01-15T10:30:00.000Z"
}
Full CRUD Example
For a complete CRUD API, create multiple endpoints:
GET /items - List Items
#![allow(unused)] fn main() { fn handle(_req: Request) -> Response { // In real app, fetch from database let items = vec![ json!({"id": "1", "name": "Item 1"}), json!({"id": "2", "name": "Item 2"}), ]; Response::ok(json!({ "items": items, "count": items.len(), })) } }
GET /items/{id} - Get Item
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let id = match req.path_param("id") { Some(id) => id, None => return Response::bad_request("Missing ID"), }; // In real app, fetch from database if id == "1" { Response::ok(json!({ "id": "1", "name": "Item 1", "price": 9.99, })) } else { Response::not_found() } } }
PUT /items/{id} - Update Item
#![allow(unused)] fn main() { #[derive(Deserialize)] struct UpdateItem { name: Option<String>, price: Option<f64>, } fn handle(req: Request) -> Response { let id = match req.path_param("id") { Some(id) => id.clone(), None => return Response::bad_request("Missing ID"), }; let update: UpdateItem = match req.json() { Ok(data) => data, Err(e) => return Response::bad_request(format!("Invalid JSON: {}", e)), }; // In real app, update in database Response::ok(json!({ "id": id, "name": update.name.unwrap_or("Unchanged".to_string()), "updated": true, })) } }
DELETE /items/{id} - Delete Item
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let id = match req.path_param("id") { Some(id) => id, None => return Response::bad_request("Missing ID"), }; // In real app, delete from database eprintln!("Deleted item: {}", id); Response::no_content() } }
Path Parameters
Extract dynamic values from URL paths.
Basic Example
Endpoint Path: /users/{id}
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { // Extract the {id} parameter let user_id = match req.path_param("id") { Some(id) => id, None => return Response::bad_request("Missing user ID"), }; Response::ok(json!({ "user_id": user_id, "message": format!("Fetching user {}", user_id), })) } handler_loop!(handle); }
Test
curl http://localhost:9080/users/123
Response
{
"user_id": "123",
"message": "Fetching user 123"
}
Multiple Parameters
Endpoint Path: /users/{user_id}/posts/{post_id}
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let user_id = req.path_param("user_id"); let post_id = req.path_param("post_id"); match (user_id, post_id) { (Some(uid), Some(pid)) => { Response::ok(json!({ "user_id": uid, "post_id": pid, })) } _ => Response::bad_request("Missing parameters"), } } }
Test
curl http://localhost:9080/users/42/posts/7
Response
{
"user_id": "42",
"post_id": "7"
}
Type Conversion
Path parameters are always strings. Convert them to other types:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let id_str = req.path_param("id") .ok_or("Missing ID")?; // Parse to integer let id: i64 = match id_str.parse() { Ok(n) => n, Err(_) => return Response::bad_request("ID must be a number"), }; // Parse to UUID let uuid_str = req.path_param("uuid") .ok_or("Missing UUID")?; let uuid = match uuid::Uuid::parse_str(uuid_str) { Ok(u) => u, Err(_) => return Response::bad_request("Invalid UUID format"), }; Response::ok(json!({ "id": id, "uuid": uuid.to_string(), })) } }
Optional Parameters with Defaults
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Get page number, default to 1 let page: u32 = req.path_param("page") .and_then(|p| p.parse().ok()) .unwrap_or(1); Response::ok(json!({"page": page})) } }
Route Patterns
| Pattern | Matches | Parameters |
|---|---|---|
/users/{id} | /users/123 | id: "123" |
/api/{version}/items | /api/v2/items | version: "v2" |
/files/{path} | /files/docs | path: "docs" |
/{org}/{repo}/issues/{num} | /acme/proj/issues/42 | org: "acme", repo: "proj", num: "42" |
Common Patterns
Resource by ID
#![allow(unused)] fn main() { // GET /users/{id} fn get_user(req: Request) -> Response { let id = req.path_param("id").unwrap(); // Fetch user by ID Response::ok(json!({"id": id, "name": "John"})) } }
Nested Resources
#![allow(unused)] fn main() { // GET /organizations/{org_id}/teams/{team_id}/members fn get_team_members(req: Request) -> Response { let org_id = req.path_param("org_id").unwrap(); let team_id = req.path_param("team_id").unwrap(); Response::ok(json!({ "organization": org_id, "team": team_id, "members": ["alice", "bob"], })) } }
Slug-based Routes
#![allow(unused)] fn main() { // GET /blog/{slug} fn get_blog_post(req: Request) -> Response { let slug = req.path_param("slug").unwrap(); // Lookup by slug Response::ok(json!({ "slug": slug, "title": format!("Post: {}", slug), })) } }
Query Parameters
Access URL query string values.
Basic Example
Endpoint Path: /search
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { // Get query parameter let query = req.query_param("q") .map(|s| s.to_string()) .unwrap_or_default(); if query.is_empty() { return Response::bad_request("Missing search query"); } Response::ok(json!({ "query": query, "results": [], })) } handler_loop!(handle); }
Test
curl "http://localhost:9080/search?q=rust"
Response
{
"query": "rust",
"results": []
}
Pagination Example
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Parse pagination parameters with defaults let page: u32 = req.query_param("page") .and_then(|p| p.parse().ok()) .unwrap_or(1); let limit: u32 = req.query_param("limit") .and_then(|l| l.parse().ok()) .unwrap_or(10) .min(100); // Cap at 100 let offset = (page - 1) * limit; Response::ok(json!({ "page": page, "limit": limit, "offset": offset, "items": [], })) } }
Test
curl "http://localhost:9080/items?page=2&limit=20"
Response
{
"page": 2,
"limit": 20,
"offset": 20,
"items": []
}
Filtering Example
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Get filter parameters let status = req.query_param("status"); let category = req.query_param("category"); let min_price: Option<f64> = req.query_param("min_price") .and_then(|p| p.parse().ok()); let max_price: Option<f64> = req.query_param("max_price") .and_then(|p| p.parse().ok()); Response::ok(json!({ "filters": { "status": status, "category": category, "price_range": { "min": min_price, "max": max_price, }, }, "items": [], })) } }
Test
curl "http://localhost:9080/products?status=active&category=electronics&min_price=100"
Sorting Example
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let sort_by = req.query_param("sort") .map(|s| s.to_string()) .unwrap_or_else(|| "created_at".to_string()); let order = req.query_param("order") .map(|s| s.to_string()) .unwrap_or_else(|| "desc".to_string()); // Validate sort field let valid_fields = ["name", "created_at", "price", "popularity"]; if !valid_fields.contains(&sort_by.as_str()) { return Response::bad_request(format!( "Invalid sort field. Valid options: {:?}", valid_fields )); } Response::ok(json!({ "sort": { "field": sort_by, "order": order, }, "items": [], })) } }
Boolean Parameters
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Parse boolean parameters let include_deleted = req.query_param("include_deleted") .map(|v| v == "true" || v == "1") .unwrap_or(false); let verbose = req.query_param("verbose") .map(|v| v == "true" || v == "1") .unwrap_or(false); Response::ok(json!({ "include_deleted": include_deleted, "verbose": verbose, })) } }
All Query Parameters
Access all query parameters at once:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { // Log all query parameters for (key, value) in &req.query { eprintln!("Query param: {} = {}", key, value); } Response::ok(json!({ "query_params": req.query, })) } }
Validation Helper
Create a reusable validation function:
#![allow(unused)] fn main() { fn parse_pagination(req: &Request) -> Result<(u32, u32), Response> { let page: u32 = req.query_param("page") .and_then(|p| p.parse().ok()) .unwrap_or(1); if page == 0 { return Err(Response::bad_request("Page must be >= 1")); } let limit: u32 = req.query_param("limit") .and_then(|l| l.parse().ok()) .unwrap_or(10); if limit > 100 { return Err(Response::bad_request("Limit must be <= 100")); } Ok((page, limit)) } fn handle(req: Request) -> Response { let (page, limit) = match parse_pagination(&req) { Ok(p) => p, Err(response) => return response, }; Response::ok(json!({"page": page, "limit": limit})) } }
Error Handling
Robust error handling patterns for handlers.
Basic Pattern
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn handle(req: Request) -> Response { match process_request(&req) { Ok(data) => Response::ok(data), Err(e) => e.to_response(), } } fn process_request(req: &Request) -> Result<JsonValue, HandlerError> { let input: CreateUser = req.json() .map_err(|e| HandlerError::ValidationError(e.to_string()))?; // Process and return result Ok(json!({"id": "123", "name": input.name})) } #[derive(Deserialize)] struct CreateUser { name: String, } handler_loop!(handle); }
Error Types and Status Codes
#![allow(unused)] fn main() { fn process(req: &Request) -> Result<JsonValue, HandlerError> { // 400 Bad Request - Invalid input if req.body.is_none() { return Err(HandlerError::ValidationError("Body required".into())); } // 401 Unauthorized - Missing/invalid auth if req.header("Authorization").is_none() { return Err(HandlerError::Unauthorized("Token required".into())); } // 404 Not Found - Resource doesn't exist let user = find_user("123"); if user.is_none() { return Err(HandlerError::NotFound("User not found".into())); } // 503 Service Unavailable - Backend down if !database_available() { return Err(HandlerError::ServiceUnavailable("Database down".into())); } // 500 Internal Error - Unexpected error if something_broke() { return Err(HandlerError::Internal("Unexpected error".into())); } Ok(json!({"status": "ok"})) } }
Input Validation
#![allow(unused)] fn main() { #[derive(Deserialize)] struct RegisterUser { email: String, password: String, name: String, } fn validate_input(input: &RegisterUser) -> Result<(), HandlerError> { // Email validation if !input.email.contains('@') { return Err(HandlerError::ValidationError( "Invalid email format".into() )); } // Password validation if input.password.len() < 8 { return Err(HandlerError::ValidationError( "Password must be at least 8 characters".into() )); } // Name validation if input.name.trim().is_empty() { return Err(HandlerError::ValidationError( "Name is required".into() )); } Ok(()) } fn handle(req: Request) -> Response { let input: RegisterUser = match req.json() { Ok(i) => i, Err(e) => return Response::bad_request(format!("Invalid JSON: {}", e)), }; if let Err(e) = validate_input(&input) { return e.to_response(); } Response::created(json!({"email": input.email})) } }
Custom Error Type
#![allow(unused)] fn main() { enum AppError { UserNotFound(String), EmailTaken(String), InvalidCredentials, RateLimited, DatabaseError(String), } impl From<AppError> for HandlerError { fn from(e: AppError) -> Self { match e { AppError::UserNotFound(id) => HandlerError::NotFound(format!("User {} not found", id)), AppError::EmailTaken(email) => HandlerError::ValidationError(format!("Email {} already registered", email)), AppError::InvalidCredentials => HandlerError::Unauthorized("Invalid email or password".into()), AppError::RateLimited => HandlerError::Internal("Rate limit exceeded".into()), AppError::DatabaseError(msg) => HandlerError::DatabaseError(msg), } } } fn process(req: &Request) -> Result<JsonValue, AppError> { // Business logic with custom errors Err(AppError::InvalidCredentials) } fn handle(req: Request) -> Response { match process(&req) { Ok(data) => Response::ok(data), Err(e) => HandlerError::from(e).to_response(), } } }
Logging Errors
Always log errors for debugging:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { match process(&req) { Ok(data) => Response::ok(data), Err(e) => { // Log with request ID for tracing eprintln!("[{}] Error: {}", req.request_id, e); // Log stack trace for internal errors if matches!(e, HandlerError::Internal(_) | HandlerError::DatabaseError(_)) { eprintln!("[{}] Request path: {}", req.request_id, req.path); eprintln!("[{}] Request body: {:?}", req.request_id, req.body); } e.to_response() } } } }
Graceful Degradation
Handle service failures gracefully:
#![allow(unused)] fn main() { fn handle(req: Request) -> Response { let redis = RedisPool { pool_id: "cache".to_string() }; let db = DbPool { pool_id: "main".to_string() }; // Try cache first let cached = redis.get("data:key"); match cached { Ok(Some(data)) => { return Response::ok(json!({"source": "cache", "data": data})); } Ok(None) => { /* Cache miss, continue */ } Err(e) => { // Log but don't fail - Redis being down shouldn't break the app eprintln!("Redis error (non-fatal): {}", e); } } // Fallback to database match db.query("SELECT * FROM data", &[]) { Ok(result) => Response::ok(json!({"source": "db", "data": result.rows})), Err(e) => { eprintln!("Database error: {}", e); Response::json(503, json!({ "error": "Service temporarily unavailable", "retry_after": 5, })) } } } }
Pet Store Demo
A complete REST API example demonstrating the same Pet Store API working with multiple storage backends.
Overview
The Pet Store demo shows how Rust Edge Gateway's Storage abstraction allows the same handler code to work with:
- SQLite - Embedded database (no external dependencies)
- PostgreSQL - Full-featured relational database
- MySQL - Popular relational database
- MinIO - Object storage (pets stored as JSON files)
- FTP/SFTP - File transfer (pets stored as JSON files)
Quick Start
cd examples/petstore
# SQLite (default - no external dependencies)
./setup.sh sqlite
# PostgreSQL
./setup.sh postgres
# MySQL
./setup.sh mysql
# MinIO (object storage)
./setup.sh minio
# FTP/SFTP (file storage)
./setup.sh ftp
API Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /pets | List all pets (optional ?status= filter) |
| POST | /pets | Create a new pet |
| GET | /pets/{petId} | Get a pet by ID |
| PUT | /pets/{petId} | Update a pet |
| DELETE | /pets/{petId} | Delete a pet |
Storage Abstraction
The key to multi-backend support is the Storage type:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; fn get_storage() -> Storage { // Choose one: Storage::database("petstore", "pets") // SQLite/PostgreSQL/MySQL Storage::object_storage("petstore", "pets") // MinIO/S3 Storage::file_storage("petstore", "pets") // FTP/SFTP } fn handle(req: Request) -> Response { let storage = get_storage(); match storage.list(None) { Ok(pets) => Response::ok(json!({"pets": pets})), Err(e) => e.to_response(), } } }
Storage API
All storage backends implement the same interface:
#![allow(unused)] fn main() { // Get a record by ID storage.get("pet-123") -> Result<Option<JsonValue>, HandlerError> // List all records (with optional filter) storage.list(Some("available")) -> Result<Vec<JsonValue>, HandlerError> // Create a new record storage.create("pet-123", &pet_json) -> Result<(), HandlerError> // Update an existing record storage.update("pet-123", &pet_json) -> Result<(), HandlerError> // Delete a record storage.delete("pet-123") -> Result<bool, HandlerError> }
Database Schema
For SQL backends, use the provided schema:
CREATE TABLE IF NOT EXISTS pets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT,
tags TEXT, -- JSON array as string
status TEXT NOT NULL DEFAULT 'available',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pets_status ON pets(status);
File Storage Format
For MinIO and FTP backends, pets are stored as individual JSON files:
/pets/
pet-001.json
pet-002.json
pet-003.json
Each file contains:
{
"id": "pet-001",
"name": "Buddy",
"category": "dog",
"tags": ["friendly", "trained"],
"status": "available",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
Handler Code
See the handler implementations in examples/petstore/handlers/:
list_pets.rs- List with optional status filterget_pet.rs- Get by IDcreate_pet.rs- Create with validationupdate_pet.rs- Partial update supportdelete_pet.rs- Delete by ID
Testing
# Create a pet
curl -X POST http://petstore.example.com/pets \
-H "Content-Type: application/json" \
-d '{"name": "Buddy", "category": "dog", "status": "available"}'
# List all pets
curl http://petstore.example.com/pets
# Filter by status
curl http://petstore.example.com/pets?status=available
# Get a specific pet
curl http://petstore.example.com/pets/pet-001
# Update a pet
curl -X PUT http://petstore.example.com/pets/pet-001 \
-H "Content-Type: application/json" \
-d '{"status": "sold"}'
# Delete a pet
curl -X DELETE http://petstore.example.com/pets/pet-001
Management API
The Rust Edge Gateway provides a REST API for managing endpoints, domains, collections, and services.
Base URL
http://localhost:9081/api
In production, access via your admin domain:
https://rust-edge-gateway.yourdomain.com/api
Response Format
All API responses follow this format:
{
"ok": true,
"data": { ... }
}
Or for errors:
{
"ok": false,
"error": "Error message"
}
Authentication
Currently, the API is open. Future versions will support authentication.
Rate Limiting
No rate limiting is currently applied to the management API.
Endpoints Overview
| Resource | Endpoints |
|---|---|
| Domains | /api/domains/* |
| Collections | /api/collections/* |
| Services | /api/services/* |
| Endpoints | /api/endpoints/* |
| Import | /api/import/* |
| System | /api/health, /api/stats |
System Endpoints
Health Check
Check if the gateway is running.
GET /api/health
Response:
{
"ok": true,
"data": {
"status": "healthy",
"version": "0.1.0"
}
}
Statistics
Get gateway statistics.
GET /api/stats
Response:
{
"ok": true,
"data": {
"endpoints_total": 10,
"endpoints_running": 8,
"requests_handled": 1234,
"uptime_seconds": 3600
}
}
Import Endpoints
Import OpenAPI Spec
Create endpoints from an OpenAPI 3.x specification.
POST /api/import/openapi
Content-Type: application/json
{
"spec": "openapi: 3.0.0\ninfo:\n title: Pet Store\n...",
"domain": "api.example.com",
"domain_id": "uuid-of-domain",
"create_collection": true
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
spec | string | Yes | OpenAPI YAML or JSON content |
domain | string | Yes | Domain to associate endpoints with |
domain_id | string | No* | Domain UUID (*required if create_collection is true) |
collection_id | string | No | Existing collection to add endpoints to |
create_collection | bool | No | Create new collection from spec info |
Response:
{
"ok": true,
"data": {
"collection": {
"id": "uuid",
"name": "Pet Store",
"base_path": "/v1"
},
"endpoints_created": 5,
"endpoints": [
{"id": "uuid", "name": "getPets", "path": "/pets", "method": "GET"},
{"id": "uuid", "name": "createPet", "path": "/pets", "method": "POST"}
]
}
}
Import Bundle (ZIP)
Upload a ZIP file containing an OpenAPI spec and handler code files.
POST /api/import/bundle?domain=api.example.com&create_collection=true&domain_id=uuid&compile=true&start=true
Content-Type: multipart/form-data
# Form field: bundle (or file, zip) = your-bundle.zip
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | Yes | Domain to associate endpoints with |
domain_id | string | No* | Domain UUID (*required if create_collection is true) |
collection_id | string | No | Existing collection to add endpoints to |
create_collection | bool | No | Create new collection from spec info |
compile | bool | No | Compile handlers after import |
start | bool | No | Start handlers after compilation (requires compile=true) |
Bundle Structure:
bundle.zip
βββ openapi.yaml # OpenAPI spec (or openapi.json, api.yaml, spec.yaml)
βββ handlers/ # Handler files (can also be at root or in src/)
βββ get_pets.rs # Matches operationId "getPets" or "get_pets"
βββ create_pet.rs # Matches operationId "createPet" or "create_pet"
βββ get_pet_by_id.rs # Matches operationId "getPetById" or "get_pet_by_id"
Handler files are matched to OpenAPI operations by normalizing names:
getPet.rsβ matches operationIdgetPetorget_petlist_all_pets.rsβ matches operationIdlistAllPetsorlist_all_pets
Response:
{
"ok": true,
"data": {
"collection": {"id": "uuid", "name": "Pet Store"},
"endpoints_created": 5,
"endpoints_updated": 0,
"handlers_matched": 5,
"compiled": 5,
"started": 5,
"endpoints": [...],
"errors": []
}
}
Example with curl:
curl -X POST "http://localhost:8081/api/import/bundle?domain=api.example.com&create_collection=true&domain_id=abc123&compile=true&start=true" \
-F "bundle=@my-api.zip"
Common Patterns
List with Filters
Most list endpoints support query parameters:
GET /api/endpoints?domain=api.example.com&enabled=true
Pagination
List endpoints will support pagination in future versions:
GET /api/endpoints?page=1&limit=20
Error Handling
Always check the ok field in responses:
const response = await fetch('/api/endpoints');
const data = await response.json();
if (data.ok) {
console.log('Endpoints:', data.data);
} else {
console.error('Error:', data.error);
}
Domains API
Domains represent the top-level organization for your endpoints (e.g., api.example.com).
List Domains
GET /api/domains
Response:
{
"ok": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Production API",
"host": "api.example.com",
"description": "Main production API",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
Create Domain
POST /api/domains
Content-Type: application/json
{
"name": "Production API",
"host": "api.example.com",
"description": "Main production API",
"enabled": true
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name for the domain |
host | string | Yes | Hostname (e.g., api.example.com) |
description | string | No | Optional description |
enabled | bool | No | Whether domain is active (default: true) |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Production API",
"host": "api.example.com",
"description": "Main production API",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Get Domain
GET /api/domains/{id}
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Production API",
"host": "api.example.com",
"description": "Main production API",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Update Domain
PUT /api/domains/{id}
Content-Type: application/json
{
"name": "Updated API Name",
"description": "Updated description",
"enabled": false
}
Request Body:
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
|---|---|---|
name | string | Display name |
host | string | Hostname |
description | string | Description |
enabled | bool | Active status |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Updated API Name",
"host": "api.example.com",
"description": "Updated description",
"enabled": false,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T11:00:00Z"
}
}
Delete Domain
DELETE /api/domains/{id}
Response:
{
"ok": true,
"data": null
}
Note: Deleting a domain will also delete all associated collections and endpoints.
Get Domain Collections
List all collections belonging to a domain.
GET /api/domains/{id}/collections
Response:
{
"ok": true,
"data": [
{
"id": "collection-uuid",
"domain_id": "domain-uuid",
"name": "Pet Store",
"description": "Pet management endpoints",
"base_path": "/pets",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
Collections API
Collections group related endpoints within a domain (e.g., "Pet Store", "User Management").
List Collections
GET /api/collections
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
domain_id | string | Filter by domain UUID |
Response:
{
"ok": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"domain_id": "domain-uuid",
"name": "Pet Store",
"description": "Pet management endpoints",
"base_path": "/pets",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
Create Collection
POST /api/collections
Content-Type: application/json
{
"domain_id": "domain-uuid",
"name": "Pet Store",
"description": "Pet management endpoints",
"base_path": "/pets",
"enabled": true
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
domain_id | string | Yes | Parent domain UUID |
name | string | Yes | Display name |
description | string | No | Optional description |
base_path | string | No | Common path prefix for endpoints |
enabled | bool | No | Whether collection is active (default: true) |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"domain_id": "domain-uuid",
"name": "Pet Store",
"description": "Pet management endpoints",
"base_path": "/pets",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Get Collection
GET /api/collections/{id}
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"domain_id": "domain-uuid",
"name": "Pet Store",
"description": "Pet management endpoints",
"base_path": "/pets",
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Update Collection
PUT /api/collections/{id}
Content-Type: application/json
{
"name": "Updated Name",
"description": "Updated description",
"base_path": "/v2/pets",
"enabled": false
}
Request Body:
All fields are optional. Only provided fields will be updated.
| Field | Type | Description |
|---|---|---|
name | string | Display name |
description | string | Description |
base_path | string | Path prefix |
enabled | bool | Active status |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"domain_id": "domain-uuid",
"name": "Updated Name",
"description": "Updated description",
"base_path": "/v2/pets",
"enabled": false,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T11:00:00Z"
}
}
Delete Collection
DELETE /api/collections/{id}
Response:
{
"ok": true,
"data": null
}
Note: Deleting a collection will also delete all associated endpoints.
Get Collection Endpoints
List all endpoints in a collection.
GET /api/collections/{id}/endpoints
Response:
{
"ok": true,
"data": [
{
"id": "endpoint-uuid",
"name": "getPets",
"path": "/pets",
"method": "GET",
"domain": "api.example.com",
"collection_id": "collection-uuid",
"description": "List all pets",
"enabled": true
}
]
}
Services API
Services represent backend connections (databases, caches, storage) that handlers can use.
List Services
GET /api/services
Response:
{
"ok": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Main Database",
"service_type": "postgres",
"config": {
"host": "db.example.com",
"port": 5432,
"database": "myapp"
},
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
Create Service
POST /api/services
Content-Type: application/json
{
"name": "Main Database",
"service_type": "postgres",
"config": {
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"username": "app_user",
"password": "secret"
},
"enabled": true
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name |
service_type | string | Yes | Type of service (see below) |
config | object | Yes | Service-specific configuration |
enabled | bool | No | Whether service is active (default: true) |
Service Types:
| Type | Description |
|---|---|
sqlite | SQLite embedded database |
postgres | PostgreSQL database |
mysql | MySQL database |
redis | Redis cache/store |
mongodb | MongoDB document database |
minio | MinIO/S3 object storage |
memcached | Memcached cache |
ftp | FTP/FTPS/SFTP file transfer |
email | SMTP email sending |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Main Database",
"service_type": "postgres",
"config": { ... },
"enabled": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Get Service
GET /api/services/{id}
Update Service
PUT /api/services/{id}
Content-Type: application/json
{
"name": "Updated Name",
"config": {
"host": "new-db.example.com"
},
"enabled": false
}
Delete Service
DELETE /api/services/{id}
Test Service Connection
Test if the service is reachable and properly configured.
POST /api/services/{id}/test
Response (Success):
{
"ok": true,
"data": {
"connected": true,
"latency_ms": 5,
"message": "Connection successful"
}
}
Response (Failure):
{
"ok": true,
"data": {
"connected": false,
"error": "Connection refused"
}
}
Service Configuration Examples
PostgreSQL
{
"service_type": "postgres",
"config": {
"host": "localhost",
"port": 5432,
"database": "myapp",
"username": "app_user",
"password": "secret",
"ssl_mode": "prefer",
"pool_size": 10
}
}
MySQL
{
"service_type": "mysql",
"config": {
"host": "localhost",
"port": 3306,
"database": "myapp",
"username": "app_user",
"password": "secret",
"use_ssl": false,
"pool_size": 10
}
}
Redis
{
"service_type": "redis",
"config": {
"host": "localhost",
"port": 6379,
"password": null,
"database": 0,
"use_tls": false,
"pool_size": 10
}
}
SQLite
{
"service_type": "sqlite",
"config": {
"path": "/data/app.db",
"create_if_missing": true
}
}
MinIO
{
"service_type": "minio",
"config": {
"endpoint": "minio.example.com:9000",
"access_key": "minioadmin",
"secret_key": "minioadmin",
"use_ssl": true,
"bucket": "uploads"
}
}
FTP/SFTP
{
"service_type": "ftp",
"config": {
"host": "sftp.example.com",
"port": 22,
"username": "user",
"password": "secret",
"protocol": "sftp",
"base_path": "/uploads",
"timeout_seconds": 30
}
}
Email (SMTP)
{
"service_type": "email",
"config": {
"host": "smtp.example.com",
"port": 587,
"username": "sender@example.com",
"password": "app-password",
"encryption": "starttls",
"from_address": "noreply@example.com",
"from_name": "My App"
}
}
Endpoints API
Endpoints are the core resource - each represents a route with handler code.
List Endpoints
GET /api/endpoints
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
domain | string | Filter by domain hostname |
collection_id | string | Filter by collection UUID |
enabled | bool | Filter by enabled status |
Response:
{
"ok": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "getPets",
"path": "/pets",
"method": "GET",
"domain": "api.example.com",
"collection_id": "collection-uuid",
"description": "List all pets",
"code": "use rust_edge_gateway_sdk::prelude::*;\n...",
"enabled": true,
"status": "running",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
Create Endpoint
POST /api/endpoints
Content-Type: application/json
{
"name": "getPets",
"path": "/pets",
"method": "GET",
"domain": "api.example.com",
"collection_id": "collection-uuid",
"description": "List all pets",
"code": "use rust_edge_gateway_sdk::prelude::*;\n\nfn handle(req: Request) -> Response {\n Response::ok(json!({\"pets\": []}))\n}\n\nhandler_loop!(handle);",
"enabled": true
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Endpoint name (for display) |
path | string | Yes | URL path pattern (e.g., /pets/{id}) |
method | string | Yes | HTTP method (GET, POST, PUT, DELETE, PATCH) |
domain | string | Yes | Domain hostname or * for all |
collection_id | string | No | Parent collection UUID |
description | string | No | Description of the endpoint |
code | string | No | Rust handler code |
enabled | bool | No | Whether endpoint is active (default: true) |
Response:
{
"ok": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "getPets",
"path": "/pets",
"method": "GET",
"domain": "api.example.com",
"collection_id": "collection-uuid",
"description": "List all pets",
"code": "...",
"enabled": true,
"status": "created",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}
Get Endpoint
GET /api/endpoints/{id}
Update Endpoint
PUT /api/endpoints/{id}
Content-Type: application/json
{
"name": "Updated Name",
"code": "// new code...",
"enabled": false
}
Delete Endpoint
DELETE /api/endpoints/{id}
Compile Endpoint
Compile the handler code into an executable.
POST /api/endpoints/{id}/compile
Response (Success):
{
"ok": true,
"data": {
"status": "compiled",
"message": "Compilation successful"
}
}
Response (Failure):
{
"ok": false,
"error": "error[E0308]: mismatched types\n --> src/main.rs:5:5\n..."
}
Start Endpoint
Start the worker process for this endpoint.
POST /api/endpoints/{id}/start
Response:
{
"ok": true,
"data": {
"status": "running",
"pid": 12345
}
}
Stop Endpoint
Stop the worker process.
POST /api/endpoints/{id}/stop
Response:
{
"ok": true,
"data": {
"status": "stopped"
}
}
Endpoint Status Values
| Status | Description |
|---|---|
created | Endpoint defined, not yet compiled |
compiled | Code compiled successfully |
running | Worker process is active |
stopped | Worker stopped, can be restarted |
error | Compilation or runtime error |
Bind Service to Endpoint
Associate a service with an endpoint.
POST /api/endpoints/{id}/services
Content-Type: application/json
{
"service_id": "service-uuid",
"pool_id": "main-db"
}
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
service_id | string | Yes | Service UUID to bind |
pool_id | string | Yes | Identifier used in handler code |
Unbind Service
DELETE /api/endpoints/{endpoint_id}/services/{service_id}