Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)    β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. Gateway receives request - The gateway matches the incoming request to an endpoint
  2. Handler is invoked - The compiled handler library is called directly via function pointer
  3. Handler processes - Your code runs with access to the Context API and Service Actors
  4. Response returned - The handler returns a Response directly to the gateway

Getting Started

The fastest way to get started is to:

  1. Access the Admin UI at /admin/
  2. Create a new endpoint
  3. Write your handler code
  4. 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

  1. Click "Create Endpoint" or the + button
  2. Fill in the endpoint details:
FieldExample ValueDescription
Namehello-worldUnique identifier for your endpoint
Path/helloThe URL path to match
MethodGETHTTP method (GET, POST, PUT, DELETE, etc.)
Domain*Domain to match (or * for all)
  1. 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:

  1. Generate a Cargo project with your code
  2. Compile it to a dynamic library (.so/.dll)
  3. 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?

Troubleshooting

Compilation Errors

Check the error message for:

  • Missing dependencies (add to your handler's use statements)
  • Syntax errors (Rust compiler messages are helpful!)
  • Type mismatches
  • Missing #[handler] attribute

Endpoint Not Responding

  1. Check the endpoint is in Loaded status
  2. Verify the path matches exactly (paths are case-sensitive)
  3. Check the method matches your request
  4. View endpoint logs in the admin UI

Handler Errors

View the logs to see panic messages or error output. Common causes:

  • Unwrapping None or Err values
  • 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

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:

StateDescription
CreatedEndpoint defined but code not yet compiled
CompiledCode compiled to dynamic library, ready to load
LoadedHandler library loaded into gateway, handling requests
DrainingOld handler finishing in-flight requests during update
ErrorCompilation or runtime error occurred

Compilation

When you click "Compile", the gateway:

  1. Creates a Cargo project in the handlers directory
  2. Writes your code to src/lib.rs
  3. Generates Cargo.toml with the SDK dependency and cdylib crate type
  4. Runs cargo build --release to compile
  5. 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:

  1. Gateway loads the dynamic library using libloading
  2. Locates the handler_entry symbol (function pointer)
  3. Registers the handler in the HandlerRegistry
  4. 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

  1. Compile new version - New handler library is compiled
  2. Load new handler - New library is loaded into memory
  3. Atomic swap - New handler starts receiving new requests
  4. Drain old handler - Old handler finishes in-flight requests
  5. 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

PatternHandler SignatureUse Case
Basicasync fn(&Context, Request) -> ResponseStandard handlers
With Resultasync 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

MethodReturnsDescription
ctx.database(name)DatabaseHandleSQL database connection
ctx.cache(name)CacheHandleKey-value cache (Redis)
ctx.storage(name)StorageHandleObject 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

MethodDescription
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 exist
  • ConnectionError - Failed to connect to the service
  • QueryError - Database query failed
  • StorageError - Storage operation failed

Actor-Based Architecture

Under the hood, services use an actor-based architecture:

  1. Service Actors run as background tasks
  2. Handlers send messages to actors via channels
  3. Actors process requests and send responses back
  4. 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

FieldTypeDescription
methodStringHTTP method: GET, POST, PUT, DELETE, PATCH, etc.
pathStringRequest path, e.g., /users/123
queryHashMap<String, String>Query parameters from the URL
headersHashMap<String, String>HTTP headers
bodyOption<String>Request body (for POST, PUT, PATCH)
paramsHashMap<String, String>Path parameters extracted from the route
client_ipOption<String>Client's IP address
request_idStringUnique 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

FieldTypeDescription
fieldsHashMap<String, String>Text form fields
filesHashMap<String, MultipartFile>Uploaded files

Methods

MethodReturnsDescription
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

MethodStatusUse Case
ok(body)200Successful GET/PUT
created(body)201Successful POST
accepted(body)202Async operation started
no_content()204Successful DELETE
bad_request(msg)400Invalid input
unauthorized(msg)401Missing/invalid auth
forbidden(msg)403Not authorized
not_found()404Resource not found
conflict(msg)409Resource conflict
internal_error(msg)500Server error
service_unavailable(msg)503Backend 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

VariantStatusUse Case
BadRequest(msg)400Invalid input, malformed JSON
ValidationError(msg)400Semantic validation failures
Unauthorized(msg)401Missing or invalid auth
Forbidden(msg)403Authenticated but not authorized
NotFound / NotFoundMessage(msg)404Resource not found
MethodNotAllowed(msg)405Wrong HTTP method
Conflict(msg)409Resource conflict (duplicate)
PayloadTooLarge(msg)413Request body too large
Internal(msg) / InternalError(msg)500Server error
DatabaseError(msg)500Database operation failed
StorageError(msg)500Storage operation failed
ServiceUnavailable(msg)503Backend 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

  1. Use handler_loop_result! - Simplifies error handling with automatic conversion
  2. Use specific error variants - BadRequest vs ValidationError vs Unauthorized
  3. Always log errors - Use eprintln! for debugging
  4. Convert early - Map external errors to HandlerError at the boundary
  5. 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:

  1. Configured in the Admin UI or via API
  2. Started as actors when the gateway launches
  3. Accessed via Context in your handler code
  4. Thread-safe through message-passing

Available Service Types

ServiceDescriptionUse Cases
PostgreSQLAdvanced relational databaseComplex queries, transactions
MySQLPopular relational databaseWeb applications, compatibility
SQLiteEmbedded SQL databaseLocal data, caching, simple apps
RedisIn-memory data storeCaching, sessions, pub/sub
MinIO/S3Object storageFile uploads, media storage
FTP/SFTPFile transfer protocolsFile uploads, vendor integrations
EmailSMTP email sendingNotifications, alerts, reports

Configuring Services

Via Admin UI

  1. Go to Services in the admin panel
  2. Click Create Service
  3. Select service type and configure connection
  4. Test the connection
  5. 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

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_name specifies 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

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

  1. Always use parameterized queries - Never concatenate user input into SQL
  2. Handle connection errors gracefully - Services may be temporarily unavailable
  3. Use appropriate pool sizes - Match your concurrency needs
  4. Keep queries simple - Complex logic is better in your handler code
  5. 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

FieldTypeDefaultDescription
hoststringrequiredRedis server hostname
portu166379Redis server port
passwordstringnullRedis password (optional)
databaseu80Redis database number (0-15)
use_tlsboolfalseEnable TLS encryption
pool_sizeu3210Connection pool size
usernamestringnullUsername 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

  1. Use meaningful key prefixes - user:123, session:abc, cache:posts:1
  2. Always set expiration for cache keys - Prevents unbounded memory growth
  3. Handle Redis unavailability - Decide on fail-open vs fail-closed
  4. Don't store large values - Redis works best with small, fast lookups
  5. 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

FieldTypeDefaultDescription
hoststringrequiredFTP server hostname
portu1621/22Server port (21 for FTP/FTPS, 22 for SFTP)
usernamestringrequiredLogin username
passwordstringnullLogin password
private_key_pathstringnullPath to SSH private key (SFTP only)
protocolstring"ftp"Protocol: "ftp", "ftps", or "sftp"
base_pathstringnullDefault directory on server
passive_modebooltrueUse passive mode (FTP/FTPS only)
timeout_secondsu3230Connection 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

  1. Prefer SFTP - Uses SSH encryption, most secure option
  2. Use FTPS if SFTP unavailable - TLS encryption for FTP
  3. Avoid plain FTP - Credentials sent in cleartext
  4. Use SSH keys - More secure than passwords for SFTP
  5. 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

FieldTypeDefaultDescription
hoststringrequiredSMTP server hostname
portu16587SMTP port
usernamestringnullAuth username (usually email address)
passwordstringnullAuth password or app password
encryptionstring"starttls"Encryption: "none", "starttls", or "tls"
from_addressstringrequiredDefault sender email
from_namestringnullDefault sender display name
reply_tostringnullDefault reply-to address
timeout_secondsu3230Connection timeout
max_retriesu323Send 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

ProviderHostPortEncryption
Gmailsmtp.gmail.com587starttls
Outlooksmtp.office365.com587starttls
SendGridsmtp.sendgrid.net587starttls
Mailgunsmtp.mailgun.org587starttls
Amazon SESemail-smtp.{region}.amazonaws.com587starttls

Best Practices

  1. Use app passwords - Don't use your main account password
  2. Set up SPF/DKIM - Improve deliverability
  3. Handle failures - Emails can fail; log and retry
  4. Rate limit - Don't spam; respect provider limits
  5. Use templates - Consistent formatting
  6. 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

  1. Request arrives at the Axum router
  2. Router matches the request to an endpoint
  3. Handler Registry looks up the handler by endpoint ID
  4. Request guard is acquired (for draining support)
  5. Handler function is called with Context and Request
  6. Handler accesses services via Context (sends messages to actors)
  7. Response is returned to the router
  8. Request guard is dropped (decrements active count)
  9. Router sends response to client

Handler Compilation

When you compile a handler:

  1. Code is written to handlers/{id}/src/lib.rs
  2. Cargo.toml is generated with SDK dependency
  3. cargo build --release compiles to dynamic library
  4. 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:

  1. New library is compiled
  2. New library is loaded into memory
  3. Registry atomically swaps the handler pointer
  4. Old handler starts draining (no new requests)
  5. Active requests complete on old handler
  6. 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

Featurev1 (Subprocess)v2 (Dynamic Library)
ExecutionChild processDirect function call
IPCstdin/stdout JSONNone (in-process)
Latency~1-5ms overhead~0.01ms overhead
MemorySeparate per handlerShared with gateway
Hot SwapRestart processAtomic pointer swap
DrainingKill processGraceful 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:

  1. Locates the library in the handlers directory
  2. Loads it with libloading (cross-platform dynamic loading)
  3. Finds the handler_entry symbol (function pointer)
  4. 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:

  1. Loads the new handler
  2. Atomically swaps the active handler
  3. Marks the old handler as draining
  4. Waits for in-flight requests to complete
  5. 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:

Statedrainingactive_requestsDescription
ActivefalseAnyAccepting new requests
Drainingtrue> 0Finishing in-flight requests
Drainedtrue0Ready 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:

PlatformLibrary Name
Linuxlibhandler_{id}.so
Windowshandler_{id}.dll
macOSlibhandler_{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

  1. Handler sends a command to the actor's inbox channel
  2. Actor receives the command in its event loop
  3. Actor executes the operation using its connection pool
  4. Actor sends the result back via a oneshot channel
  5. 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 Result values

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

  1. Service created - Configuration stored in database
  2. Service activated - Actor task spawns, connects to backend
  3. Requests arrive - Handlers send commands to actor via channel
  4. Actor processes - Executes operations, returns results via oneshot
  5. Service deactivated - Actor completes in-flight ops, shuts down
  6. 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:

EndpointMethodDescription
/api/minio/objectsGETList objects (with optional ?prefix=)
/api/minio/objectsPOSTUpload file (multipart form)
/api/minio/objects/{key}GETDownload file
/api/minio/objects/{key}DELETEDelete 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:

  1. Loading the new handler before removing the old one
  2. Routing new requests to the new handler immediately
  3. Allowing old requests to complete on the old handler
  4. 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_requests on creation
  • Decrements active_requests on drop
  • Returns None if 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:

  1. Loads the new handler
  2. Atomically swaps the active handler
  3. Marks the old handler as draining
  4. Spawns a background task to monitor draining
  5. 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:

TimeoutUse Case
5sFast APIs with quick responses
30sStandard web applications
60sLong-running operations
300sFile 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

SettingValue
Path/hello
MethodGET
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

SettingValue
Path/items
MethodPOST
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

PatternMatchesParameters
/users/{id}/users/123id: "123"
/api/{version}/items/api/v2/itemsversion: "v2"
/files/{path}/files/docspath: "docs"
/{org}/{repo}/issues/{num}/acme/proj/issues/42org: "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

MethodPathDescription
GET/petsList all pets (optional ?status= filter)
POST/petsCreate 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 filter
  • get_pet.rs - Get by ID
  • create_pet.rs - Create with validation
  • update_pet.rs - Partial update support
  • delete_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

ResourceEndpoints
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:

FieldTypeRequiredDescription
specstringYesOpenAPI YAML or JSON content
domainstringYesDomain to associate endpoints with
domain_idstringNo*Domain UUID (*required if create_collection is true)
collection_idstringNoExisting collection to add endpoints to
create_collectionboolNoCreate 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:

ParameterTypeRequiredDescription
domainstringYesDomain to associate endpoints with
domain_idstringNo*Domain UUID (*required if create_collection is true)
collection_idstringNoExisting collection to add endpoints to
create_collectionboolNoCreate new collection from spec info
compileboolNoCompile handlers after import
startboolNoStart 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 operationId getPet or get_pet
  • list_all_pets.rs β†’ matches operationId listAllPets or list_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:

FieldTypeRequiredDescription
namestringYesDisplay name for the domain
hoststringYesHostname (e.g., api.example.com)
descriptionstringNoOptional description
enabledboolNoWhether 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.

FieldTypeDescription
namestringDisplay name
hoststringHostname
descriptionstringDescription
enabledboolActive 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:

ParameterTypeDescription
domain_idstringFilter 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:

FieldTypeRequiredDescription
domain_idstringYesParent domain UUID
namestringYesDisplay name
descriptionstringNoOptional description
base_pathstringNoCommon path prefix for endpoints
enabledboolNoWhether 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.

FieldTypeDescription
namestringDisplay name
descriptionstringDescription
base_pathstringPath prefix
enabledboolActive 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:

FieldTypeRequiredDescription
namestringYesDisplay name
service_typestringYesType of service (see below)
configobjectYesService-specific configuration
enabledboolNoWhether service is active (default: true)

Service Types:

TypeDescription
sqliteSQLite embedded database
postgresPostgreSQL database
mysqlMySQL database
redisRedis cache/store
mongodbMongoDB document database
minioMinIO/S3 object storage
memcachedMemcached cache
ftpFTP/FTPS/SFTP file transfer
emailSMTP 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:

ParameterTypeDescription
domainstringFilter by domain hostname
collection_idstringFilter by collection UUID
enabledboolFilter 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:

FieldTypeRequiredDescription
namestringYesEndpoint name (for display)
pathstringYesURL path pattern (e.g., /pets/{id})
methodstringYesHTTP method (GET, POST, PUT, DELETE, PATCH)
domainstringYesDomain hostname or * for all
collection_idstringNoParent collection UUID
descriptionstringNoDescription of the endpoint
codestringNoRust handler code
dependenciesobjectNoCustom Cargo dependencies (mirrors Cargo.toml format)
enabledboolNoWhether 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

StatusDescription
createdEndpoint defined, not yet compiled
compiledCode compiled successfully
runningWorker process is active
stoppedWorker stopped, can be restarted
errorCompilation 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:

FieldTypeRequiredDescription
service_idstringYesService UUID to bind
pool_idstringYesIdentifier used in handler code

Unbind Service

DELETE /api/endpoints/{endpoint_id}/services/{service_id}