ullun-api 0.2.1

Express.js-inspired web framework for Rust - simple, fast, batteries-included
Documentation

ullun-api

Express.js-inspired web framework for Rust — simple, fast, batteries-included

CI Security Audit Crates.io Downloads Documentation License Rust

codecov No Unsafe Benchmarks

GitHub stars GitHub forks GitHub issues GitHub contributors

⚠️ Production Note: Requires proper deployment (TLS, auth, rate limiting). See Production Readiness below.

ullun (уллун; Yakutian/Sakha) = "large/great" — powerful yet simple

Why ullun-api?

The Problem: Actix-web is fast but complex. Rocket is ergonomic but heavy. Axum ties you to Tokio internals.

The Solution: An Express.js-style framework for Rust — single-file APIs, minimal boilerplate, maximum performance.

use ullun::prelude::*;

#[tokio::main]
async fn main() {
    App::new()
        .get("/", |_req| async { Ok(Response::text("Hello, World!")) })
        .get_with_params("/hello/{name}", |params| async move {
            Ok(Response::json(serde_json::json!({
                "message": format!("Hello, {}!", params.get("name")?)
            })))
        })
        .run("127.0.0.1:3000")
        .await
        .unwrap();
}

Features

  • 🚀 Fast: Built on hyper + tokio, measured numbers in PERFORMANCE.md
  • 🎯 Simple: Express.js-style routing, zero boilerplate
  • 🔒 Type-safe: Path parameters validated at compile-time
  • 🔗 Middleware: Chain middleware like Express.js
  • 📦 Batteries-included: JSON serialization, error handling, CORS built-in
  • 🎨 Ergonomic: Result<T, E> maps to HTTP status codes automatically
  • 🍪 Cookie Support: Parse and set HTTP cookies with ease
  • 📁 Static Files: Serve static files from directories
  • 🎯 Route Groups: Organize routes with common prefixes

⚠️ Production Readiness

ullun-api is production-ready for small-to-medium APIs (<100k users), but requires proper deployment practices:

✅ Framework Provides

  • Memory safety (zero unsafe code)
  • Type-safe routing
  • Graceful error handling
  • CORS & security headers middleware

⚠️ User Must Implement

  • Authentication/Authorization - Use middleware (see examples/todo_api.rs)
  • Rate Limiting - Use reverse proxy (nginx/Caddy) or custom middleware
  • TLS/HTTPS - Use reverse proxy (never expose raw HTTP in production)
  • Input Validation - Validate business logic (framework validates types)
  • SQL Injection Prevention - Use parameterized queries (sqlx, diesel)
  • XSS Protection - Sanitize HTML output (if serving HTML)

📋 Pre-Production Checklist

  1. ✅ Implement authentication middleware
  2. ✅ Use reverse proxy (nginx/Caddy) for TLS termination
  3. ✅ Add monitoring/observability (Prometheus, Sentry)
  4. ✅ Follow SECURITY.md checklist
  5. ✅ Load test your specific workload (wrk, hey, ab)
  6. ✅ Set up error logging and alerting

See SECURITY.md for comprehensive security guidelines.


Installation

[dependencies]
ullun-api = "0.2"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Quick Start

Hello World

use ullun::prelude::*;

#[tokio::main]
async fn main() {
    App::new()
        .get("/", |_req| async {
            Ok(Response::text("Hello, World!"))
        })
        .run("127.0.0.1:3000")
        .await
        .unwrap();
}

Path Parameters

App::new()
    .get_with_params("/users/{id}", |params| async move {
        let id: u64 = params.get("id")?.parse()
            .map_err(|_| Error::bad_request("Invalid user ID"))?;
        
        Ok(Response::json(serde_json::json!({
            "user_id": id
        })))
    })
    .run("127.0.0.1:3000")
    .await
    .unwrap();

JSON Request Body

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

async fn create_user(req: Request) -> Result<Response> {
    let body: CreateUser = req.json()?;
    
    Ok(Response::json(serde_json::json!({
        "message": format!("Created user: {}", body.name)
    })))
}

App::new()
    .post("/users", create_user)
    .run("127.0.0.1:3000")
    .await
    .unwrap();

Middleware

use ullun::middleware::{logger, cors};

App::new()
    .middleware(logger)
    .middleware(cors(vec!["*".to_string()]))
    .get("/", |_req| async { Ok(Response::text("Hello!")) })
    .run("127.0.0.1:3000")
    .await
    .unwrap();

Route Groups (v0.2.0)

App::new()
    .group("/api", |group| {
        group
            .get("/users", list_users)
            .post("/users", create_user)
    })
    .group("/admin", |group| {
        group
            .get("/dashboard", admin_dashboard)
            .post("/settings", update_settings)
    })
    .run("127.0.0.1:3000")
    .await?;

Cookie Handling (v0.2.0)

use ullun::cookies::{Cookie, SameSite};

// Reading cookies
async fn get_session(req: Request) -> Result<Response> {
    let cookies = req.cookies();
    if let Some(session_id) = cookies.get("session") {
        Ok(Response::text(format!("Session: {}", session_id)))
    } else {
        Err(Error::unauthorized("Not logged in"))
    }
}

// Setting cookies
async fn login(_req: Request) -> Result<Response> {
    let cookie = Cookie::new("session", "abc123")
        .path("/")
        .max_age(3600)
        .http_only()
        .secure()
        .same_site(SameSite::Lax);
    
    Ok(Response::text("Logged in").cookie(cookie))
}

Static Files (v0.2.0)

App::new()
    .serve_static("/", "public")        // Serve from 'public' directory
    .serve_static("/assets", "dist")    // Serve from 'dist' at /assets
    .run("127.0.0.1:3000")
    .await?;

Body Size Limits (v0.2.0)

App::new()
    .max_body_size(Some(5 * 1024 * 1024))  // 5 MB limit
    // or
    .max_body_size(None)                    // Unlimited (not recommended)
    .run("127.0.0.1:3000")
    .await?;

API Reference

App

let app = App::new()
    .get(path, handler)           // GET route
    .post(path, handler)          // POST route
    .put(path, handler)           // PUT route
    .delete(path, handler)        // DELETE route
    .get_with_params(path, handler) // GET with path params extractor
    .middleware(middleware)       // Add middleware
    .run(addr);                   // Start server (graceful shutdown on CTRL+C)

Need the bound port, or no signal handler (tests, benchmarks, embedding)? Bind yourself and use serve:

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
println!("port {}", listener.local_addr()?.port());
app.serve(listener).await?;

Middleware runs for every request, including unmatched routes (404) and CORS preflight (OPTIONS).

Handlers

Handlers can be:

  • Async functions taking Request → Result<Response>
  • Closures with path params: Params → Result<Response>
// Full request access
async fn handler(req: Request) -> Result<Response> {
    let body = req.json::<MyType>()?;
    Ok(Response::json(body))
}

// Path params only
.get_with_params("/users/{id}", |params| async move {
    let id = params.get("id")?;
    // ...
})

Error Handling

Errors automatically map to HTTP status codes:

return Err(Error::bad_request("Invalid input"));      // 400
return Err(Error::unauthorized("Login required"));    // 401
return Err(Error::forbidden("Access denied"));        // 403
return Err(Error::not_found("User not found"));       // 404
return Err(Error::internal("Database error"));        // 500
return Err(Error::custom(418, "I'm a teapot"));       // Custom

Middleware

Create custom middleware:

async fn my_middleware(req: Request, next: Next) -> Result<Response> {
    // Before handler
    println!("Before: {}", req.uri);
    
    let response = next.run(req).await?;
    
    // After handler
    println!("After: {}", response.status);
    
    Ok(response)
}

App::new()
    .middleware(my_middleware)
    // ...

Examples

See the examples/ directory. Basics:

Scenarios (each is a self-contained deployment shape):

Scenario Example Shows
Containerised microservice microservice.rs /healthz + /readyz probes, request ids, Prometheus metrics, PORT config, graceful shutdown
Public API for third parties public_api.rs API keys, per-key rate limiting with retry-after, CORS preflight, versioned group, pagination
SPA + backend in one binary spa_backend.rs Static frontend at /, JSON API under /api, security headers
Webhook receiver webhook_receiver.rs Constant-time token check, body limit, idempotency, 202 + background worker
Server-rendered HTML html_forms.rs Form posts, cookie sessions, flash messages, Post/Redirect/Get, HTML escaping

Run examples:

cargo run --example hello_world
cargo run --example microservice
PORT=8080 cargo run --example public_api   # scenario examples read PORT (default 3000)

Test the API:

# GET /
curl http://localhost:3000/

# GET with path params
curl http://localhost:3000/hello/world

# POST with JSON
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com"}'

Comparison

Framework Learning Curve Middleware Style
Actix-web High Complex Actor/extractor based
Rocket Medium Fairings Macro/attribute based
Axum Medium Tower layers Extractor based
ullun-api Low Express-style (req, next) Closures and async fn(Request)

This table is qualitative. We do not publish cross-framework speed claims; see PERFORMANCE.md for what is measured.

Testing & Benchmarks

# Run tests
cargo test

# Run benchmarks (micro + end-to-end HTTP)
cargo bench

# Check coverage
cargo tarpaulin --out Html

See TESTING.md and PERFORMANCE.md for details.

Security

✅ No unsafe code
✅ Memory safe (Rust guarantees)
✅ No unwrap() in production paths
✅ Header validation (prevents malformed headers)
✅ Graceful error handling (no panics)

⚠️ User Responsibilities:

  • Authentication/authorization implementation
  • Rate limiting (reverse proxy or middleware)
  • Input validation (business logic)
  • SQL injection prevention (parameterized queries)
  • XSS protection (if serving HTML)

See SECURITY.md for comprehensive guidelines and best practices.

Roadmap

Done

  • Route groups, cookies, static file serving, body size limits (0.2)
  • Scenario examples, end-to-end HTTP benchmarks

Next

  • Group-level middleware (RouteGroup::middleware is accepted but ignored today)
  • OpenAPI/Swagger generation
  • WebSocket support
  • Form data parsing helpers
  • Rate limiting, compression and JWT helpers (patterns in examples/public_api.rs)
  • Template engine integration

CI/CD

GitHub Actions (.github/workflows/ci.yml):

  • ✅ Lint: rustfmt, clippy -D warnings, rustdoc, cargo audit on every PR and on main
  • ✅ Test: Linux, macOS, Windows
  • ✅ Build: release build of all examples, benchmarks compile
  • ✅ Commits: PR title and commits must be Conventional Commits
  • ✅ Release: on main, only after lint, tests and build passed, release-plz derives the next version from the commits, maintains a release PR with the changelog, and on merge tags, creates the GitHub release and publishes to crates.io

See CONTRIBUTING.md for the details and required secrets.

Contributing

Contributions welcome! Commits follow Conventional Commits; see CONTRIBUTING.md for guidelines.

License

Licensed under either of:

at your option.

Documentation

Acknowledgments


Made with ❤️ by yedoma-labs