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 dynamic libraries and loaded directly into the gateway process, providing:
- π Native Performance - Handlers compile to optimized native code (.so/.dll)
- β‘ Zero-Copy Execution - Direct function calls, no serialization overhead
- π Hot Reload - Swap handlers without restarting the gateway
- π Actor-Based Services - Database, cache, and storage via message-passing
- π Graceful Draining - Zero-downtime deployments with request draining
- π οΈ Simple SDK - Easy-to-use Context, Request, and Response API
How It Works
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β Client ββββββΆβ Edge Gateway ββββββΆβ Your Handler β
β (Browser, β β (Routes & β β (Dynamic β
β API, etc) βββββββ Manages) βββββββ Library .so) β
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β
βΌ
βββββββββββββββββ
β Service Actorsβ
β (DB, Cache, β
β Storage) β
βββββββββββββββββ
- Gateway receives request - The gateway matches the incoming request to an endpoint
- Handler is invoked - The compiled handler library is called directly via function pointer
- Handler processes - Your code runs with access to the Context API and Service Actors
- Response returned - The handler returns a Response directly to 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 deploy
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::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { Response::ok(json!({ "message": "Hello, World!", "path": req.path, "method": req.method, })) } }
The SDK provides:
- Context - Access to Service Actors (database, cache, storage)
- 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, cache, and storage service actors
Architecture
Rust Edge Gateway uses a dynamic library loading model with actor-based services:
- Main Gateway - Axum-based HTTP server handling routing
- Handler Registry - Manages loaded handler libraries with hot-swap support
- Dynamic Libraries - Your compiled handlers as
.so(Linux),.dll(Windows), or.dylib(macOS) - Service Actors - Message-passing based services for database, cache, and storage
- Graceful Draining - Old handlers complete in-flight requests during updates
This architecture provides:
- Performance - Direct function calls with zero serialization overhead
- Hot Swapping - Replace handlers without gateway restart
- Zero Downtime - Graceful draining ensures no dropped requests during updates
- Scalability - Async handlers with Tokio runtime for high concurrency
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::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { Response::ok(json!({ "message": "Hello from Rust Edge Gateway!", "path": req.path, "method": req.method, })) } }
Step 4: Compile
Click the "Compile" button. The gateway will:
- Generate a Cargo project with your code
- Compile it to a dynamic library (
.so/.dll) - Report success or any compilation errors
You should see a success message like:
β Compiled successfully in 2.3s
Step 5: Deploy the Endpoint
Click "Deploy" to load the handler. The status should change to Loaded.
The handler is now active and receiving requests - no separate "start" step needed!
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"
}
What's Next?
- Your First Handler - Deeper dive into handler structure
- Handler Lifecycle - Understand compilation, loading, and hot-swapping
- Context API - Access services via the Context
- 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
- Missing
#[handler]attribute
Endpoint Not Responding
- Check the endpoint is in Loaded status
- Verify the path matches exactly (paths are case-sensitive)
- Check the method matches your request
- View endpoint logs in the admin UI
Handler Errors
View the logs to see panic messages or error output. Common causes:
- Unwrapping
NoneorErrvalues - Accessing invalid JSON fields
- Service actor communication errors
Your First Handler
This guide explains the structure of a handler and how to work with the Context, Request, and Response.
Handler Structure
Every handler follows the same pattern:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // Your logic here Response::ok(json!({"status": "success"})) } }
The Prelude
The prelude module imports everything you typically need:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; // This imports: // - Context for service access // - Request, Response types // - serde::{Deserialize, Serialize} // - serde_json::{json, Value as JsonValue} // - HandlerError for error handling // - The #[handler] attribute macro }
The Handler Function
Your handler function receives a Context and Request, and returns a Response:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // Access request data let method = &req.method; // "GET", "POST", etc. let path = &req.path; // "/users/123" // Access services via ctx (database, cache, storage) // let db = ctx.database(); // Return a response Response::ok(json!({"received": path})) } }
The Handler Attribute
The #[handler] attribute macro generates the entry point for the dynamic library:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // ... } // This generates: // #[no_mangle] // pub extern "C" fn handler_entry(ctx: &Context, req: Request) -> Pin<Box<dyn Future<Output = Response> + Send>> { // Box::pin(handle(ctx, req)) // } }
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, } #[handler] pub async fn handle(ctx: &Context, 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() { #[handler] pub async fn handle(ctx: &Context, 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() { #[handler] pub async fn handle(ctx: &Context, 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() { #[handler] pub async fn handle(ctx: &Context, 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") }
Using the Context
The Context provides access to Service Actors:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // Access database service let db = ctx.database("main-db").await?; let users = db.query("SELECT * FROM users").await?; // Access cache service let cache = ctx.cache("redis").await?; cache.set("key", "value", 300).await?; // Access storage service let storage = ctx.storage("s3").await?; storage.put("file.txt", data).await?; Response::ok(json!({"users": users})) } }
Next Steps
- Handler Lifecycle - Compilation, loading, and hot-swapping
- Context API - Service access via Context
- Error Handling - Structured error handling
- Examples - More code examples
Handler Lifecycle
Understanding how handlers are compiled, loaded, 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 to dynamic library, ready to load |
| Loaded | Handler library loaded into gateway, handling requests |
| Draining | Old handler finishing in-flight requests during update |
| 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/lib.rs - Generates Cargo.toml with the SDK dependency and
cdylibcrate type - Runs
cargo build --releaseto compile - Produces a dynamic library (
.so,.dll, or.dylib)
Generated Project Structure
handlers/
βββ {endpoint-id}/
βββ Cargo.toml
βββ Cargo.lock
βββ src/
β βββ lib.rs # Your handler code
βββ target/
βββ release/
βββ libhandler_{id}.so # Compiled library (Linux)
Cargo.toml
The generated Cargo.toml includes the SDK and configures a dynamic library:
[package]
name = "handler_{endpoint_id}"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
rust-edge-gateway-sdk = { path = "../../crates/rust-edge-gateway-sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Handler Loading
Loading a Handler
When you deploy an endpoint:
- Gateway loads the dynamic library using
libloading - Locates the
handler_entrysymbol (function pointer) - Registers the handler in the
HandlerRegistry - Status changes to "Loaded"
Request Flow
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β Request ββββββΆβ HandlerRegistry ββββββΆβ handler_entry β
β β β (lookup by ID) β β (fn pointer) β
β βββββββ βββββββ β
βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
The handler is called directly via function pointer - no serialization or IPC overhead.
Handler Function
Your handler is an async function that receives a Context and Request:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // Access services via ctx // Process request // Return response Response::ok(json!({"status": "success"})) } }
The #[handler] macro generates the handler_entry symbol that the gateway looks for.
Hot Swapping with Graceful Draining
Rust Edge Gateway supports zero-downtime updates with graceful draining:
How It Works
- Compile new version - New handler library is compiled
- Load new handler - New library is loaded into memory
- Atomic swap - New handler starts receiving new requests
- Drain old handler - Old handler finishes in-flight requests
- Unload old handler - Once drained, old library is unloaded
Request Tracking
Each handler tracks active requests:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Handler Update Timeline β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Time βββββββββββββββββββββββββββββββββββββββββββββββββββΆ β
β β
β Old Handler: βββββββββββββββββββββββββββββββββββββββββββ β
β (handling) (draining) (unloaded) β
β β
β New Handler: ββββββββββββββββββββββββββββββββββββββββββββ β
β (handling new requests) β
β β
β β² β
β β Swap point β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Drain Timeout
If the old handler doesn't drain within the timeout (default: 30 seconds), it is forcefully unloaded. Configure this based on your longest expected request duration.
Error Handling
Compilation Errors
If compilation fails:
- Error message is captured and displayed
- Endpoint stays in previous state
- Previous library (if any) remains loaded
Runtime Errors
If your handler panics:
- The panic is caught by the gateway
- Error is logged
- Other handlers continue working
- The specific request returns a 500 error
Graceful Error Handling
Always handle errors in your code:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { match process_request(ctx, &req).await { Ok(data) => Response::ok(data), Err(e) => e.to_response(), // HandlerError -> Response } } async fn process_request(ctx: &Context, req: &Request) -> Result<JsonValue, HandlerError> { let body: MyInput = req.json() .map_err(|e| HandlerError::ValidationError(e.to_string()))?; // Use services via ctx // ... process ... Ok(json!({"result": "success"})) } }
Handler Attribute
The SDK provides the #[handler] attribute macro for creating handler entry points.
Quick Reference
| Pattern | Handler Signature | Use Case |
|---|---|---|
| Basic | async fn(&Context, Request) -> Response | Standard handlers |
| With Result | async fn(&Context, Request) -> Result<Response, HandlerError> | Error handling with ? |
The Handler Attribute
The #[handler] attribute generates the dynamic library entry point:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { Response::ok(json!({"path": req.path, "method": req.method})) } }
This generates a handler_entry symbol that the gateway loads and calls directly.
Handler Signature
All handlers receive:
ctx: &Context- Access to Service Actors (database, cache, storage)req: Request- The incoming HTTP request
And return:
Response- The HTTP response to send
Basic Handler
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { Response::ok(json!({ "message": "Hello!", "path": req.path })) } }
Handler with Error Handling
For handlers that use the ? operator, return Result<Response, HandlerError>:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[derive(Deserialize)] struct CreateItem { name: String, price: f64, } #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { // These all use ? operator - errors become HTTP responses let auth = req.require_header("Authorization")?; let item: CreateItem = req.json()?; if item.price < 0.0 { return Err(HandlerError::ValidationError("Price cannot be negative".into())); } Ok(Response::created(json!({"name": item.name}))) } }
Using Services via Context
The Context provides access to Service Actors:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { // Database operations let db = ctx.database("main-db").await?; let users = db.query("SELECT * FROM users WHERE active = $1", &[&true]).await?; // Cache operations let cache = ctx.cache("redis").await?; if let Some(cached) = cache.get("users:all").await? { return Ok(Response::ok(cached)); } // Storage operations let storage = ctx.storage("s3").await?; let file = storage.get("config.json").await?; Ok(Response::ok(json!({"users": users}))) } }
Async by Default
All handlers are async - the gateway runs them on a Tokio runtime:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // You can use .await directly let data = fetch_from_api().await; // Concurrent operations let (users, products) = tokio::join!( fetch_users(), fetch_products() ); Response::ok(json!({"users": users, "products": products})) } }
Example: Complete CRUD Handler
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { match (req.method.as_str(), req.path.as_str()) { ("GET", "/items") => list_items(ctx).await, ("POST", "/items") => create_item(ctx, &req).await, ("GET", _) if req.path.starts_with("/items/") => get_item(ctx, &req).await, ("DELETE", _) if req.path.starts_with("/items/") => delete_item(ctx, &req).await, _ => Err(HandlerError::MethodNotAllowed("Use GET, POST, or DELETE".into())), } } async fn list_items(ctx: &Context) -> Result<Response, HandlerError> { let db = ctx.database("main-db").await?; let items = db.query("SELECT * FROM items", &[]).await?; Ok(Response::ok(json!({"items": items}))) } async fn create_item(ctx: &Context, req: &Request) -> Result<Response, HandlerError> { let item: NewItem = req.json()?; let db = ctx.database("main-db").await?; let id = db.execute("INSERT INTO items (name) VALUES ($1) RETURNING id", &[&item.name]).await?; Ok(Response::created(json!({"id": id}))) } async fn get_item(ctx: &Context, req: &Request) -> Result<Response, HandlerError> { let id = req.path.strip_prefix("/items/").unwrap_or(""); let db = ctx.database("main-db").await?; let item = db.query_one("SELECT * FROM items WHERE id = $1", &[&id]).await?; Ok(Response::ok(item)) } async fn delete_item(ctx: &Context, req: &Request) -> Result<Response, HandlerError> { let id = req.path.strip_prefix("/items/").unwrap_or(""); let db = ctx.database("main-db").await?; db.execute("DELETE FROM items WHERE id = $1", &[&id]).await?; Ok(Response::no_content()) } }
Context API
The Context provides access to Service Actors from within your handler. It's the bridge between your handler code and backend services like databases, caches, and storage.
Overview
Every handler receives a Context reference:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Response { // Use ctx to access services let db = ctx.database("main-db").await?; // ... } }
Available Services
| Method | Returns | Description |
|---|---|---|
ctx.database(name) | DatabaseHandle | SQL database connection |
ctx.cache(name) | CacheHandle | Key-value cache (Redis) |
ctx.storage(name) | StorageHandle | Object storage (S3/MinIO) |
Database Access
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let db = ctx.database("main-db").await?; // Query with parameters let users = db.query( "SELECT id, name, email FROM users WHERE active = $1", &[&true] ).await?; // Execute (INSERT, UPDATE, DELETE) let affected = db.execute( "UPDATE users SET last_login = NOW() WHERE id = $1", &[&user_id] ).await?; // Query single row let user = db.query_one( "SELECT * FROM users WHERE id = $1", &[&user_id] ).await?; Ok(Response::ok(json!({"users": users}))) } }
Cache Access
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let cache = ctx.cache("redis").await?; // Try cache first if let Some(cached) = cache.get("users:all").await? { return Ok(Response::ok(cached)); } // Cache miss - fetch from database let db = ctx.database("main-db").await?; let users = db.query("SELECT * FROM users", &[]).await?; // Store in cache with TTL (seconds) cache.set("users:all", &users, 300).await?; Ok(Response::ok(json!({"users": users}))) } }
Cache Operations
| Method | Description |
|---|---|
get(key) | Get value by key |
set(key, value, ttl) | Set value with TTL in seconds |
delete(key) | Delete a key |
exists(key) | Check if key exists |
incr(key) | Increment numeric value |
decr(key) | Decrement numeric value |
Storage Access
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let storage = ctx.storage("s3").await?; // Upload file let data = req.body_bytes(); storage.put("uploads/file.txt", data).await?; // Download file let content = storage.get("config/settings.json").await?; // List files let files = storage.list("uploads/").await?; // Delete file storage.delete("uploads/old-file.txt").await?; // Get signed URL (for direct client access) let url = storage.presigned_url("uploads/file.txt", 3600).await?; Ok(Response::ok(json!({"url": url}))) } }
Service Configuration
Services are configured in the Admin UI or via the Management API. Each service has a unique name that you use to access it:
#![allow(unused)] fn main() { // These names come from your service configuration let main_db = ctx.database("main-db").await?; let read_replica = ctx.database("read-replica").await?; let session_cache = ctx.cache("sessions").await?; let file_storage = ctx.storage("uploads").await?; }
Error Handling
Service operations return Result types that can be used with ?:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { // Service errors are automatically converted to HandlerError let db = ctx.database("main-db").await?; let users = db.query("SELECT * FROM users", &[]).await?; Ok(Response::ok(json!({"users": users}))) } }
Common error types:
ServiceNotFound- The named service doesn't existConnectionError- Failed to connect to the serviceQueryError- Database query failedStorageError- Storage operation failed
Actor-Based Architecture
Under the hood, services use an actor-based architecture:
- Service Actors run as background tasks
- Handlers send messages to actors via channels
- Actors process requests and send responses back
- Connection pooling is handled automatically
This provides:
- Isolation - Service failures don't crash handlers
- Concurrency - Multiple handlers can share services safely
- Efficiency - Connection pools are reused across requests
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 connects your handlers to backend services via Service Actors. Services are accessed through the Context API using an actor-based message-passing architecture.
Overview
Services are:
- Configured in the Admin UI or via API
- Started as actors when the gateway launches
- Accessed via Context in your handler code
- Thread-safe through message-passing
Available Service Types
| Service | Description | Use Cases |
|---|---|---|
| PostgreSQL | Advanced relational database | Complex queries, transactions |
| MySQL | Popular relational database | Web applications, compatibility |
| SQLite | Embedded SQL database | Local data, caching, simple apps |
| Redis | In-memory data store | Caching, sessions, pub/sub |
| MinIO/S3 | Object storage | File uploads, media storage |
| 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
- Save the service
Via API
curl -X POST http://localhost:9081/api/services \
-H "Content-Type: application/json" \
-d '{
"name": "main-db",
"service_type": "postgres",
"config": {
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"username": "app_user",
"password": "secret",
"pool_size": 10
}
}'
Using Services in Handlers
Services are accessed through the Context:
Database Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let db = ctx.database("main-db").await?; // Query with parameters let users = db.query( "SELECT id, name FROM users WHERE active = $1", &[&true] ).await?; Ok(Response::ok(json!({"users": users}))) } }
Cache Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let cache = ctx.cache("redis").await?; // Try cache first if let Some(cached) = cache.get("user:123").await? { return Ok(Response::ok(json!({"source": "cache", "data": cached}))); } // Cache miss - fetch from database let db = ctx.database("main-db").await?; let user = db.query_one("SELECT * FROM users WHERE id = $1", &[&123]).await?; // Store in cache (TTL in seconds) cache.set("user:123", &user, 300).await?; Ok(Response::ok(json!({"source": "db", "data": user}))) } }
Storage Example
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let storage = ctx.storage("s3").await?; // Upload file let data = req.body_bytes(); storage.put("uploads/file.txt", data).await?; // Get presigned URL for client download let url = storage.presigned_url("uploads/file.txt", 3600).await?; Ok(Response::ok(json!({"download_url": url}))) } }
Actor-Based Architecture
Services use the actor pattern for thread-safety:
ββββββββββββ βββββββββββββββ ββββββββββββββββ
β Handler ββββββΆβ Channel ββββββΆβ Service Actorβ
β β β (command) β β β
β βββββββ (response) βββββββ (owns pool) β
ββββββββββββ βββββββββββββββ ββββββββββββββββ
Benefits:
- Thread-safe - No shared mutable state
- Isolated - Actor failures don't crash handlers
- Efficient - Connection pools are reused
- Backpressure - Channel buffers prevent overload
Service Names
Services are identified by name in your handler code:
#![allow(unused)] fn main() { // These names come from your service configuration let main_db = ctx.database("main-db").await?; let read_replica = ctx.database("read-replica").await?; let session_cache = ctx.cache("sessions").await?; let file_storage = ctx.storage("uploads").await?; }
This allows the same handler code to use different service instances in different environments.
Next Steps
- Context API - Full Context reference
- Database Service Details
- Cache (Redis) Details
- Storage Details
- Architecture: Service Actors
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
Architecture Overview
Rust Edge Gateway uses a dynamic library loading architecture with actor-based services for high performance and zero-downtime deployments.
High-Level Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Edge Gateway β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββββββ β
β β Router β β Admin β β Handler Registry β β
β β (Axum) β β API β β (Dynamic Libraries) β β
β ββββββββ¬βββββββ βββββββββββββββ βββββββββββββ¬ββββββββββββββ β
β β β β
β β βββββββββββββββββββββββββββββββ β
β β β β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β Service Actors ββ
β β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ ββ
β β β Database β β Cache β β Storage β β Email β ββ
β β β Actor β β Actor β β Actor β β Actor β ββ
β β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Core Components
Router (Axum)
The main HTTP server built on Axum:
- Receives incoming HTTP requests
- Matches requests to endpoints by path, method, and domain
- Dispatches to the appropriate handler
- Returns responses to clients
Handler Registry
Manages loaded handler libraries:
- Loads dynamic libraries (
.so,.dll,.dylib) - Maintains a map of endpoint ID to handler function
- Supports hot-swapping with graceful draining
- Tracks active requests per handler
Service Actors
Background tasks that manage backend connections:
- Database Actor - Connection pooling for SQL databases
- Cache Actor - Redis/Memcached connections
- Storage Actor - S3/MinIO object storage
- Email Actor - SMTP connections
Actors communicate via message-passing channels, providing isolation and thread-safety.
Admin API
RESTful API for management:
- Create/update/delete endpoints
- Compile handler code
- Deploy/undeploy handlers
- Configure services
- View logs and metrics
Request Flow
- Request arrives at the Axum router
- Router matches the request to an endpoint
- Handler Registry looks up the handler by endpoint ID
- Request guard is acquired (for draining support)
- Handler function is called with Context and Request
- Handler accesses services via Context (sends messages to actors)
- Response is returned to the router
- Request guard is dropped (decrements active count)
- Router sends response to client
Handler Compilation
When you compile a handler:
- Code is written to
handlers/{id}/src/lib.rs - Cargo.toml is generated with SDK dependency
cargo build --releasecompiles to dynamic library- Library is stored in
handlers/{id}/target/release/
The generated library exports a handler_entry symbol that the registry loads.
Hot Swapping
When you update a handler:
- New library is compiled
- New library is loaded into memory
- Registry atomically swaps the handler pointer
- Old handler starts draining (no new requests)
- Active requests complete on old handler
- Old library is unloaded when drained
This provides zero-downtime deployments.
Service Actor Pattern
Services use the actor pattern for safety and efficiency:
ββββββββββββ βββββββββββββββ ββββββββββββββββ
β Handler ββββββΆβ Channel ββββββΆβ Service Actorβ
β β β (mpsc) β β β
β βββββββ βββββββ (owns pool) β
ββββββββββββ βββββββββββββββ ββββββββββββββββ
Benefits:
- Thread-safe - No shared mutable state
- Isolated - Actor failures don't crash handlers
- Efficient - Connection pools are reused
- Backpressure - Channel buffers prevent overload
Comparison with v1
| Feature | v1 (Subprocess) | v2 (Dynamic Library) |
|---|---|---|
| Execution | Child process | Direct function call |
| IPC | stdin/stdout JSON | None (in-process) |
| Latency | ~1-5ms overhead | ~0.01ms overhead |
| Memory | Separate per handler | Shared with gateway |
| Hot Swap | Restart process | Atomic pointer swap |
| Draining | Kill process | Graceful completion |
Handler Registry
The Handler Registry manages loaded handler libraries and provides hot-swapping with graceful draining.
Overview
#![allow(unused)] fn main() { pub struct HandlerRegistry { /// Map of endpoint ID to loaded handler handlers: RwLock<HashMap<String, Arc<LoadedHandler>>>, /// Handlers that are draining (previous versions) draining_handlers: RwLock<Vec<Arc<LoadedHandler>>>, /// Directory where handler libraries are stored handlers_dir: PathBuf, } }
Loading Handlers
When a handler is deployed, the registry:
- Locates the library in the handlers directory
- Loads it with
libloading(cross-platform dynamic loading) - Finds the
handler_entrysymbol (function pointer) - Stores in the handlers map by endpoint ID
#![allow(unused)] fn main() { // Load a handler registry.load("my-endpoint").await?; // Load from specific path registry.load_from("my-endpoint", Path::new("/path/to/lib.so")).await?; }
Executing Handlers
The registry provides execution methods with request tracking:
#![allow(unused)] fn main() { // Execute a handler let response = registry.execute("my-endpoint", &ctx, request).await?; // Execute with timeout let response = registry.execute_with_timeout( "my-endpoint", &ctx, request, Duration::from_secs(30) ).await?; }
Request tracking ensures graceful draining works correctly.
Hot Swapping
Immediate Swap
For quick updates where in-flight requests can be dropped:
#![allow(unused)] fn main() { registry.swap("my-endpoint", Path::new("/path/to/new-lib.so")).await?; }
The old handler is dropped immediately.
Graceful Swap
For zero-downtime updates:
#![allow(unused)] fn main() { let result = registry.swap_graceful( "my-endpoint", Path::new("/path/to/new-lib.so"), Duration::from_secs(30) // drain timeout ).await?; println!("Swapped: {}", result.swapped); println!("Pending requests: {}", result.old_requests_pending); println!("Draining: {}", result.draining); }
The graceful swap:
- Loads the new handler
- Atomically swaps the active handler
- Marks the old handler as draining
- Waits for in-flight requests to complete
- Unloads the old handler when drained
Request Tracking
Each LoadedHandler tracks active requests:
#![allow(unused)] fn main() { pub struct LoadedHandler { // ... library and entry point ... /// Active request count active_requests: AtomicU64, /// Whether this handler is draining draining: AtomicBool, } }
When executing a handler:
#![allow(unused)] fn main() { // Acquire request guard (increments counter) let guard = handler.acquire_request()?; // Execute handler let response = handler.execute(&ctx, request).await; // Guard is dropped (decrements counter) drop(guard); }
If the handler is draining, acquire_request() returns None.
Draining States
A handler can be in one of these states:
| State | draining | active_requests | Description |
|---|---|---|---|
| Active | false | Any | Accepting new requests |
| Draining | true | > 0 | Finishing in-flight requests |
| Drained | true | 0 | Ready to unload |
Monitoring
Get statistics about loaded handlers:
#![allow(unused)] fn main() { let stats = registry.stats().await; println!("Loaded handlers: {}", stats.loaded_count); println!("Draining handlers: {}", stats.draining_count); println!("Active requests: {}", stats.active_requests); println!("Draining requests: {}", stats.draining_requests); }
Cleanup
Periodically clean up fully drained handlers:
#![allow(unused)] fn main() { let removed = registry.cleanup_drained().await; println!("Cleaned up {} drained handlers", removed); }
This is typically called by a background task.
Library Naming
Libraries are named by platform:
| Platform | Library Name |
|---|---|
| Linux | libhandler_{id}.so |
| Windows | handler_{id}.dll |
| macOS | libhandler_{id}.dylib |
The registry handles this automatically based on the target platform.
Service Actors
Service Actors provide thread-safe access to backend services using the actor pattern.
Actor Pattern
Each service runs as an independent actor:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Service Actor β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Inbox ββββββΆβ Actor ββββββΆβ Backend β β
β β (Channel) β β Loop β β (Pool) β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β² β β
β β βΌ β
β βββββββββββββββ βββββββββββββββ β
β β Handlers βββββββ Response β β
β β (Callers) β β Channel β β
β βββββββββββββββ βββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
How It Works
- Handler sends a command to the actor's inbox channel
- Actor receives the command in its event loop
- Actor executes the operation using its connection pool
- Actor sends the result back via a oneshot channel
- Handler receives the result and continues
Actor Types
Database Actor
Manages SQL database connections:
#![allow(unused)] fn main() { pub enum DatabaseCommand { Query { sql: String, params: Vec<Value>, reply: oneshot::Sender<Result<Vec<Row>>>, }, Execute { sql: String, params: Vec<Value>, reply: oneshot::Sender<Result<u64>>, }, } }
Cache Actor
Manages Redis/Memcached connections:
#![allow(unused)] fn main() { pub enum CacheCommand { Get { key: String, reply: oneshot::Sender<Result<Option<String>>>, }, Set { key: String, value: String, ttl: Option<u64>, reply: oneshot::Sender<Result<()>>, }, Delete { key: String, reply: oneshot::Sender<Result<bool>>, }, } }
MinIO/Storage Actor
Manages object storage (S3/MinIO). This is a fully implemented service actor:
#![allow(unused)] fn main() { pub enum MinioCommand { GetObject { key: String, reply: oneshot::Sender<Result<Vec<u8>, String>>, }, PutObject { key: String, data: Vec<u8>, content_type: Option<String>, reply: oneshot::Sender<Result<(), String>>, }, DeleteObject { key: String, reply: oneshot::Sender<Result<(), String>>, }, ListObjects { prefix: Option<String>, reply: oneshot::Sender<Result<Vec<ObjectInfo>, String>>, }, } }
MinIO Actor Implementation
The actor runs as an async task with an S3 bucket connection:
#![allow(unused)] fn main() { pub struct MinioHandle { sender: mpsc::Sender<MinioCommand>, bucket_name: String, } impl MinioHandle { pub async fn spawn(config: &MinioConfig) -> Result<Self> { let (tx, mut rx) = mpsc::channel(100); let bucket = create_s3_bucket(config)?; tokio::spawn(async move { while let Some(cmd) = rx.recv().await { match cmd { MinioCommand::GetObject { key, reply } => { let result = bucket.get_object(&key).await; let _ = reply.send(result.map(|r| r.to_vec())); } MinioCommand::PutObject { key, data, content_type, reply } => { let ct = content_type.as_deref().unwrap_or("application/octet-stream"); let result = bucket.put_object_with_content_type(&key, &data, ct).await; let _ = reply.send(result.map(|_| ())); } // ... other commands } } }); Ok(MinioHandle { sender: tx, bucket_name: config.bucket.clone() }) } } }
Using the MinIO Actor
Handlers communicate with the actor via async message passing:
#![allow(unused)] fn main() { // Get an object let (tx, rx) = oneshot::channel(); minio_handle.sender.send(MinioCommand::GetObject { key: "uploads/file.txt".to_string(), reply: tx, }).await?; let data = rx.await??; // List objects let (tx, rx) = oneshot::channel(); minio_handle.sender.send(MinioCommand::ListObjects { prefix: Some("uploads/".to_string()), reply: tx, }).await?; let objects = rx.await??; }
Actor Handle
Handlers interact with actors through handles:
#![allow(unused)] fn main() { pub struct ActorHandle<C> { sender: mpsc::Sender<C>, } impl<C> ActorHandle<C> { pub async fn send(&self, command: C) -> Result<()> { self.sender.send(command).await?; Ok(()) } } }
Benefits
Thread Safety
Actors own their resources exclusively:
- No shared mutable state
- No locks needed
- No data races possible
Isolation
Actor failures are contained:
- A crashed actor doesn't crash handlers
- Actors can be restarted independently
- Errors are returned as
Resultvalues
Backpressure
Channel buffers provide natural backpressure:
- If an actor is overloaded, senders wait
- Prevents resource exhaustion
- Configurable buffer sizes
Connection Pooling
Actors manage connection pools:
- Connections are reused across requests
- Pool size is configurable
- Automatic reconnection on failure
Configuration
Actors are configured via the Admin UI or API. First create the service configuration:
{
"name": "my-storage",
"service_type": "minio",
"config": {
"endpoint": "minio:9000",
"access_key": "minioadmin",
"secret_key": "minioadmin",
"bucket": "my-bucket",
"use_ssl": false,
"region": "us-east-1"
}
}
Then activate the service actor:
POST /api/services/{id}/activate
Lifecycle
- Service created - Configuration stored in database
- Service activated - Actor task spawns, connects to backend
- Requests arrive - Handlers send commands to actor via channel
- Actor processes - Executes operations, returns results via oneshot
- Service deactivated - Actor completes in-flight ops, shuts down
- Gateway stops - All actors gracefully shut down
Actors can be activated/deactivated at runtime without restarting the gateway.
REST Endpoints for MinIO
Once a MinIO service is activated, built-in handlers expose REST endpoints:
| Endpoint | Method | Description |
|---|---|---|
/api/minio/objects | GET | List objects (with optional ?prefix=) |
/api/minio/objects | POST | Upload file (multipart form) |
/api/minio/objects/{key} | GET | Download file |
/api/minio/objects/{key} | DELETE | Delete file |
These handlers communicate with the MinIO actor via message passing, ensuring thread-safe access to the S3 bucket.
Graceful Draining
Graceful draining enables zero-downtime deployments by allowing old handlers to complete in-flight requests while new handlers receive new requests.
The Problem
Without graceful draining, updating a handler can cause:
- Dropped requests - In-flight requests are terminated
- Connection resets - Clients see connection errors
- Data corruption - Partial operations may leave inconsistent state
The Solution
Graceful draining solves this by:
- Loading the new handler before removing the old one
- Routing new requests to the new handler immediately
- Allowing old requests to complete on the old handler
- Unloading the old handler only when fully drained
Timeline
Time βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΆ
Old Handler: βββββββββββββββββββββββββββββββββββββββββββββββββ
(handling) (draining) (unloaded)
New Handler: ββββββββββββββββββββββββββββββββββββββββββββββββββββ
(handling new requests)
β²
β Swap point
How It Works
1. Request Tracking
Each handler tracks active requests using atomic counters:
#![allow(unused)] fn main() { pub struct LoadedHandler { active_requests: AtomicU64, draining: AtomicBool, } }
2. Request Guards
When a request starts, a guard is acquired:
#![allow(unused)] fn main() { let guard = handler.acquire_request()?; // Request is processed... // Guard is dropped when request completes }
The guard:
- Increments
active_requestson creation - Decrements
active_requestson drop - Returns
Noneif handler is draining
3. Graceful Swap
When swapping handlers:
#![allow(unused)] fn main() { let result = registry.swap_graceful( "my-endpoint", new_library_path, Duration::from_secs(30) // drain timeout ).await?; }
This:
- Loads the new handler
- Atomically swaps the active handler
- Marks the old handler as draining
- Spawns a background task to monitor draining
- Returns immediately (non-blocking)
4. Drain Monitoring
A background task monitors the old handler:
#![allow(unused)] fn main() { while !old_handler.is_drained() { if elapsed > drain_timeout { // Force unload after timeout break; } tokio::time::sleep(Duration::from_millis(100)).await; } // Old handler is now safe to unload }
API
Swap with Draining
#![allow(unused)] fn main() { let result = registry.swap_graceful( endpoint_id, new_path, drain_timeout ).await?; // Result contains: // - swapped: bool - Whether swap succeeded // - old_requests_pending: u64 - Requests still in flight // - draining: bool - Whether old handler is draining }
Check Draining Status
#![allow(unused)] fn main() { // Is handler accepting new requests? let accepting = !handler.is_draining(); // Is handler fully drained? let drained = handler.is_drained(); // How many requests are in flight? let active = handler.active_request_count(); }
Get Statistics
#![allow(unused)] fn main() { let stats = registry.stats().await; println!("Active handlers: {}", stats.loaded_count); println!("Draining handlers: {}", stats.draining_count); println!("Active requests: {}", stats.active_requests); println!("Draining requests: {}", stats.draining_requests); }
Drain Timeout
The drain timeout determines how long to wait for requests to complete:
| Timeout | Use Case |
|---|---|
| 5s | Fast APIs with quick responses |
| 30s | Standard web applications |
| 60s | Long-running operations |
| 300s | File uploads, batch processing |
If the timeout expires, the old handler is forcefully unloaded. Any remaining requests will fail.
Best Practices
1. Set Appropriate Timeouts
Match your drain timeout to your longest expected request:
#![allow(unused)] fn main() { // For a file upload endpoint registry.swap_graceful( "upload-endpoint", new_path, Duration::from_secs(300) // 5 minutes for large uploads ).await?; }
2. Monitor Draining
Log draining status for observability:
#![allow(unused)] fn main() { if result.draining { tracing::info!( endpoint = endpoint_id, pending = result.old_requests_pending, "Handler draining" ); } }
3. Handle Drain Rejection
When a handler is draining, new requests are rejected:
#![allow(unused)] fn main() { match handler.acquire_request() { Some(guard) => { // Process request } None => { // Handler is draining, return 503 return Response::service_unavailable("Handler updating, retry shortly"); } } }
4. Cleanup Drained Handlers
Periodically clean up fully drained handlers:
#![allow(unused)] fn main() { // In a background task loop { registry.cleanup_drained().await; tokio::time::sleep(Duration::from_secs(60)).await; } }
Hello World
The simplest possible handler.
Code
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(_ctx: &Context, _req: Request) -> Response { Response::ok(json!({ "message": "Hello, World!" })) } }
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() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, _req: Request) -> Response { Response::text(200, "Hello, World!") } }
HTML
#![allow(unused)] fn main() { #[handler] pub async fn handle(_ctx: &Context, _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, } #[handler] pub async fn handle(_ctx: &Context, 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 via ctx) let item = Item { id: uuid::Uuid::new_v4().to_string(), name: input.name, description: input.description, price: input.price, }; // Return 201 Created Response::created(item) } }
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 with database access:
GET /items - List Items
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, _req: Request) -> Result<Response, HandlerError> { let db = ctx.database("main-db").await?; let items = db.query("SELECT id, name FROM items", &[]).await?; Ok(Response::ok(json!({ "items": items, "count": items.len(), }))) } }
GET /items/{id} - Get Item
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let id = req.path_param("id") .ok_or_else(|| HandlerError::ValidationError("Missing ID".into()))?; let db = ctx.database("main-db").await?; let item = db.query_one("SELECT * FROM items WHERE id = $1", &[&id]).await?; Ok(Response::ok(item)) } }
PUT /items/{id} - Update Item
#![allow(unused)] fn main() { #[derive(Deserialize)] struct UpdateItem { name: Option<String>, price: Option<f64>, } #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let id = req.path_param("id") .ok_or_else(|| HandlerError::ValidationError("Missing ID".into()))?; let update: UpdateItem = req.json()?; let db = ctx.database("main-db").await?; db.execute( "UPDATE items SET name = COALESCE($1, name), price = COALESCE($2, price) WHERE id = $3", &[&update.name, &update.price, &id] ).await?; Ok(Response::ok(json!({"id": id, "updated": true}))) } }
DELETE /items/{id} - Delete Item
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let id = req.path_param("id") .ok_or_else(|| HandlerError::ValidationError("Missing ID".into()))?; let db = ctx.database("main-db").await?; db.execute("DELETE FROM items WHERE id = $1", &[&id]).await?; Ok(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::*; #[handler] pub async fn handle(_ctx: &Context, 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), })) } }
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() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, req: Request) -> Response { let id_str = match req.path_param("id") { Some(id) => id, None => return Response::bad_request("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 = match req.path_param("uuid") { Some(u) => u, None => return Response::bad_request("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() { #[handler] pub async fn handle(_ctx: &Context, 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} #[handler] pub async fn get_user(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let id = req.path_param("id").unwrap(); let db = ctx.database("main-db").await?; let user = db.query_one("SELECT * FROM users WHERE id = $1", &[&id]).await?; Ok(Response::ok(user)) } }
Nested Resources
#![allow(unused)] fn main() { // GET /organizations/{org_id}/teams/{team_id}/members #[handler] pub async fn get_team_members(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let org_id = req.path_param("org_id").unwrap(); let team_id = req.path_param("team_id").unwrap(); let db = ctx.database("main-db").await?; let members = db.query( "SELECT * FROM members WHERE org_id = $1 AND team_id = $2", &[&org_id, &team_id] ).await?; Ok(Response::ok(json!({ "organization": org_id, "team": team_id, "members": members, }))) } }
Slug-based Routes
#![allow(unused)] fn main() { // GET /blog/{slug} #[handler] pub async fn get_blog_post(ctx: &Context, req: Request) -> Result<Response, HandlerError> { let slug = req.path_param("slug").unwrap(); let db = ctx.database("main-db").await?; let post = db.query_one("SELECT * FROM posts WHERE slug = $1", &[&slug]).await?; Ok(Response::ok(post)) } }
Query Parameters
Access URL query string values.
Basic Example
Endpoint Path: /search
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(_ctx: &Context, 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": [], })) } }
Test
curl "http://localhost:9080/search?q=rust"
Response
{
"query": "rust",
"results": []
}
Pagination Example
#![allow(unused)] fn main() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, 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() { #[handler] pub async fn handle(_ctx: &Context, 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)) } #[handler] pub async fn handle(_ctx: &Context, 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::*; #[handler] pub async fn handle(_ctx: &Context, req: Request) -> Result<Response, HandlerError> { let data = process_request(&req)?; Ok(Response::ok(data)) } 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, } }
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(()) } #[handler] pub async fn handle(_ctx: &Context, req: Request) -> Result<Response, HandlerError> { let input: RegisterUser = req.json()?; validate_input(&input)?; Ok(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) } #[handler] pub async fn handle(_ctx: &Context, req: Request) -> Result<Response, HandlerError> { let data = process(&req)?; Ok(Response::ok(data)) } }
Logging Errors
Always log errors for debugging:
#![allow(unused)] fn main() { #[handler] pub async fn handle(_ctx: &Context, req: Request) -> Result<Response, HandlerError> { match process(&req) { Ok(data) => Ok(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); } Err(e) } } } }
Graceful Degradation
Handle service failures gracefully:
#![allow(unused)] fn main() { #[handler] pub async fn handle(ctx: &Context, _req: Request) -> Result<Response, HandlerError> { let cache = ctx.cache("redis").await; let db = ctx.database("main").await?; // Try cache first (non-fatal if unavailable) if let Ok(cache) = cache { match cache.get("data:key").await { Ok(Some(data)) => { return Ok(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", &[]).await { Ok(result) => Ok(Response::ok(json!({"source": "db", "data": result}))), Err(e) => { eprintln!("Database error: {}", e); Ok(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 Context API:
#![allow(unused)] fn main() { use rust_edge_gateway_sdk::prelude::*; #[handler] pub async fn handle(ctx: &Context, _req: Request) -> Result<Response, HandlerError> { // Access storage via Context - backend is configured in Admin UI let storage = ctx.storage("petstore").await?; let pets = storage.list("pets/").await?; Ok(Response::ok(json!({"pets": pets}))) } }
The storage backend (database, S3, FTP) is configured in the Admin UI, not in code.
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)
βββ bundle.yaml # Optional manifest with dependencies
βββ 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"
Bundle Manifest (bundle.yaml):
The optional bundle.yaml file can specify dependencies shared by all handlers:
bundle:
name: my-api
version: 1.0.0
dependencies:
regex: "1.10"
chrono:
version: "0.4"
features:
- serde
uuid:
version: "1.0"
features:
- v4
- serde
routes:
- method: GET
path: /pets
handler: get_pets
- method: POST
path: /pets
handler: create_pet
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}
Activate Service
Start the service actor. This spawns an async task that manages connections to the backend service.
POST /api/services/{id}/activate
Response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my-storage",
"service_type": "minio",
"active": true,
"message": "MinIO service actor started successfully"
}
}
Deactivate Service
Stop the service actor. In-flight operations complete before shutdown.
POST /api/services/{id}/deactivate
Response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my-storage",
"service_type": "minio",
"active": false,
"message": "Service deactivated"
}
}
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"
}
}
MinIO File Operations
When a MinIO service is activated, the following endpoints become available for file operations:
List Objects
GET /api/minio/objects
GET /api/minio/objects?prefix=uploads/
Response:
{
"bucket": "my-bucket",
"prefix": "",
"objects": [
{
"key": "uploads/file.txt",
"size": 1234,
"last_modified": "2025-12-17T00:29:55.205Z"
}
]
}
Upload Object
Upload a file using multipart form data.
POST /api/minio/objects
Content-Type: multipart/form-data
file: (binary data)
key: uploads/myfile.txt
Response:
{
"key": "uploads/myfile.txt",
"bucket": "my-bucket",
"size": 1234,
"message": "Upload successful"
}
Download Object
GET /api/minio/objects/{key}
GET /api/minio/objects/uploads/myfile.txt
Returns the file content with appropriate Content-Type header based on file extension.
Delete Object
DELETE /api/minio/objects/{key}
DELETE /api/minio/objects/uploads/myfile.txt
Response:
{
"key": "uploads/myfile.txt",
"bucket": "my-bucket",
"deleted": true
}
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);",
"dependencies": {
"regex": "1.10",
"chrono": { "version": "0.4", "features": ["serde"] }
},
"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 |
dependencies | object | No | Custom Cargo dependencies (mirrors Cargo.toml format) |
enabled | bool | No | Whether endpoint is active (default: true) |
Dependencies Format
The dependencies field accepts an object where keys are crate names and values can be:
- Simple version:
"regex": "1.10" - With features:
"chrono": { "version": "0.4", "features": ["serde"] } - Optional:
"tokio": { "version": "1", "optional": true }
Example with multiple dependencies:
{
"dependencies": {
"regex": "1.10",
"chrono": { "version": "0.4", "features": ["serde"] },
"uuid": { "version": "1.0", "features": ["v4", "serde"] }
}
}
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...",
"dependencies": {
"regex": "1.10"
},
"enabled": false
}
All fields are optional. Only provided fields are updated.
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}