Skip to content

benjaminPla/astor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

astor

Crates.io docs.rs License: MIT CI

HTTP for Rust services behind a reverse proxy. Does its job. Goes home.

Two dependencies — [matchit] for routing, tokio for async I/O. No hyper. No middleware stack you didn't ask for.


The idea

You're running behind nginx. nginx already handles TLS, rate limiting, slow clients, and body-size limits. Every general-purpose Rust HTTP framework re-implements all of that. astor doesn't — that's the whole point.

What changes between services is exactly what astor covers:

  • Routing — radix tree, O(path-length), {name} parameter syntax
  • Typed responses — named status codes, response shortcuts, typed builder
  • Graceful shutdown — SIGTERM / Ctrl-C, drains in-flight requests before exit

Quick start

# Cargo.toml
[dependencies]
astor = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
use astor::{Method, Request, Response, Router, Server, Status};

#[tokio::main]
async fn main() {
    let app = Router::new()
        .on(Method::Get,    "/users",      list_users,  ())
        .on(Method::Get,    "/users/{id}", get_user,    ())
        .on(Method::Post,   "/users",      create_user, ())
        .on(Method::Delete, "/users/{id}", delete_user, ());

    Server::bind("0.0.0.0:3000").serve(app).await.unwrap();
}

// Path params: {name} syntax — req.param("id") → Option<&str>
async fn get_user(req: Request) -> Response {
    let id = req.param("id").unwrap_or("unknown");
    Response::json(format!(r#"{{"id":"{id}"}}"#).into_bytes())
}

// Query params pre-parsed — req.query("key") → Option<&str>
// GET /users?page=2&limit=10 still matches the /users route
async fn list_users(req: Request) -> Response {
    let page  = req.query("page").unwrap_or("1");
    let limit = req.query("limit").unwrap_or("20");
    Response::json(format!(r#"{{"page":{page},"limit":{limit},"users":[]}}"#).into_bytes())
}

// req.body() → &[u8] — parse with serde_json, simd-json, or anything else
async fn create_user(req: Request) -> Response {
    if req.body().is_empty() {
        return Response::status(Status::BadRequest);
    }
    Response::builder()
        .status(Status::Created)
        .header("location", "/users/99")
        .json(r#"{"id":"99"}"#.to_owned().into_bytes())
}

// Return Status directly — astor wraps it into a response
async fn delete_user(_req: Request) -> Status { Status::NoContent }
cargo run --example basic
curl http://localhost:3000/users/42
curl "http://localhost:3000/users?page=2&limit=10"

Middleware

Middleware runs as an ordered chain: global (router-level) first, then per-route extras, then the handler. The chain is baked into each route at startup — no runtime composition.

use astor::{Next, Request, Response, Router, Status};

async fn require_auth(req: Request, next: Next) -> Response {
    match req.header("authorization") {
        Some(_) => next.call(req).await,           // proceed
        None    => Response::status(Status::Unauthorized), // short-circuit
    }
}

async fn ownership_check(req: Request, next: Next) -> Response {
    // verify ownership, then proceed
    next.call(req).await
}

Global middleware applies to every route registered after .middleware():

let users = Router::new()
    .middleware(require_auth)                                    // applied to all routes below
    .on(Method::Get,    "/users",      list_users,   ())        // chain: [require_auth]
    .on(Method::Put,    "/users/{id}", update_user,  ownership_check) // chain: [require_auth, ownership_check]
    .on(Method::Delete, "/users/{id}", delete_user,  ownership_check);

Sub-router composition via .merge() — each sub-router keeps its own chain:

let public   = Router::new().on(Method::Get, "/health", health, ());
let users    = Router::new().middleware(require_auth).on(/* ... */);
let products = Router::new().middleware(require_auth).on(/* ... */);

let app = Router::new().merge(public).merge(users).merge(products);
Server::bind("0.0.0.0:3000").serve(app).await.unwrap();

Pass () as the fourth argument to .on() when a route needs no extra middleware.


Status codes are a type, not a number

No free-form response constructor. No raw integers. Every status code is a named [Status] variant — tab-completable, greppable, compiler-verified.

Response::status(Status::NoContent)   // 204 — not "204", not 204
Response::status(Status::NotFound)    // 404

Response::builder()
    .status(Status::Created)
    .header("location", "/users/42")
    .json(bytes)

// Return Status directly from a handler
async fn delete_user(_req: Request) -> Status { Status::NoContent }

Every IANA-registered code from 100 to 511 — full list on docs.rs/astor.


Full API reference, nginx config, and Kubernetes deployment guide: docs.rs/astor


Contributing

Contributions are welcome. Read CONTRIBUTING.md before opening a PR. See CHANGELOG.md for release history.


License

MIT

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages