astor 0.2.0

A fast, minimal HTTP framework for reverse-proxy deployments
Documentation

astor

Crates.io docs.rs License: MIT CI

Minimal HTTP framework for Rust. Lives behind nginx. Does its job. Goes home.

Your nginx handles TLS. Your nginx handles rate limiting. Your nginx handles slow clients, body sizes, and half the other things frameworks love to re-implement.

So what exactly is your framework supposed to duplicate?

Nothing. astor doesn't touch any of that. The proxy does proxy things. The framework does framework things. This is not a controversial opinion.


The deal

astor sits behind nginx or ingress-nginx. The proxy covers the hard, boring, already-solved stuff. astor covers your routes.

What the proxy already owns — and why we sleep soundly knowing it:

nginx / ingress handles this what astor thinks about it
Body-size limits client_max_body_size in nginx. Done.
HTTP/2 + HTTP/3 to clients nginx negotiates protocol. astor speaks plain HTTP/1.1.
Rate limiting limit_req or ingress-nginx annotations. Not our concern.
Slow-client & DDoS protection nginx timeouts and buffers. We trust nginx.
TLS termination nginx SSL / k8s ingress TLS. Obviously.

What's left for astor — which is, coincidentally, the only part that changes between applications:

What How
Async I/O tokio
Graceful shutdown SIGTERM + Ctrl-C — drains in-flight requests before exit
Health probes /healthz and /readyz built in
Radix-tree routing [matchit] — O(path-length) lookup

Dependencies

astor keeps its dependency tree minimal by design. It speaks raw HTTP/1.1 over tokio — no hyper, no tower, no middleware stack you didn't ask for.

Every crate that lives in [dependencies] is there because the alternative is re-implementing it badly. Everything else is your problem, not ours. You want logging? Bring your own. You want tracing? Wire it up in your app. astor surfaces errors as Result — catch them, log them however you like.


Quick start

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

#[tokio::main]
async fn main() {
    let app = Router::new()
        .on(Method::Get, "/healthz",   health::liveness)
        .on(Method::Get, "/readyz",    health::readiness)
        .on(Method::Get, "/users/{id}", get_user);

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

async fn get_user(req: Request) -> Response {
    let id = req.param("id").unwrap_or("unknown");
    Response::json(format!(r#"{{"id":"{id}"}}"#).into_bytes())
}

Routing

use astor::Method;

router
    .on(Method::Delete,  "/users/{id}",          delete_user)
    .on(Method::Get,     "/users/{id}",          get_user)
    .on(Method::Get,     "/orgs/{org}/repos/{repo}", get_repo)
    .on(Method::Options, "/users",              options_users)
    .on(Method::Patch,   "/users/{id}",          update_user)
    .on(Method::Post,    "/users",              create_user);

Path parameters via req.param("name"):

async fn get_repo(req: Request) -> Response {
    let org  = req.param("org").unwrap();
    let repo = req.param("repo").unwrap();
    Response::text(format!("{org}/{repo}"))
}

Responses

Status codes

All status codes go through Status. Every IANA-registered code is a named variant — no magic integers:

use astor::Status;

Status::Ok                     // 200
Status::Created                // 201
Status::NoContent              // 204
Status::BadRequest             // 400
Status::Unauthorized           // 401
Status::NotFound               // 404
Status::UnprocessableContent   // 422
Status::TooManyRequests        // 429
Status::InternalServerError    // 500
Status::ServiceUnavailable     // 503

Shortcuts — 200 OK, no custom headers needed

use astor::{Response, Status};

// JSON — bytes from your serialiser, directly. No intermediate allocation.
// serde_json:  Response::json(serde_json::to_vec(&val).unwrap())
// hand-built:  Response::json(format!(r#"{{"id":{id}}}"#).into_bytes())
Response::json(bytes)

// Plain text
Response::text("hello")

// No body
Response::status(Status::NoContent)
Response::status(Status::NotFound)

Builder — custom status or extra headers

Ends with a typed body call. You always know exactly what you're sending.

use astor::{Response, ContentType, Status};

// 201 Created with Location header, JSON body
Response::builder()
    .status(Status::Created)
    .header("location", "/users/42")
    .json(bytes)

// 301 redirect — no body
Response::builder()
    .status(Status::MovedPermanently)
    .header("location", "/new-path")
    .no_body()

// Any content-type via the ContentType enum
Response::builder()
    .status(Status::Ok)
    .bytes(ContentType::Xml, b"<users/>".to_vec())

ContentType enum

Variant Content-Type header
ContentType::Csv text/csv
ContentType::EventStream text/event-stream
ContentType::FormData application/x-www-form-urlencoded
ContentType::Html text/html; charset=utf-8
ContentType::Json application/json
ContentType::MsgPack application/msgpack
ContentType::OctetStream application/octet-stream
ContentType::Pdf application/pdf
ContentType::Text text/plain; charset=utf-8
ContentType::Xml application/xml

Reading request bodies

req.body() returns &[u8]. Parse it however you want — astor never touches the bytes:

async fn create_user(req: Request) -> Response {
    if req.body().is_empty() {
        return Response::status(Status::BadRequest);
    }
    let user: User = serde_json::from_slice(req.body()).unwrap();
    Response::json(serde_json::to_vec(&user).unwrap())
}

Custom return types with IntoResponse

Implement IntoResponse on your own types and return them directly from handlers. No Response construction scattered across every call site:

use astor::{IntoResponse, Response, Status};
use serde::Serialize;

struct Json<T: Serialize>(T);

impl<T: Serialize> IntoResponse for Json<T> {
    fn into_response(self) -> Response {
        match serde_json::to_vec(&self.0) {
            Ok(bytes) => Response::json(bytes),
            Err(_)    => Response::status(Status::InternalServerError),
        }
    }
}

// Handler return type is inferred — no Response construction at the call site.
async fn get_user(_req: Request) -> Json<User> {
    Json(User { id: 1, name: "alice".into() })
}

Built-in IntoResponse impls: Response, String, &'static str, Status.

// Return Status directly — astor wraps it. No boilerplate.
async fn delete_user(_req: Request) -> Status { Status::NoContent }

Health checks

Kubernetes needs to know if your pod is alive and ready. Two endpoints. Always 200 if the process can respond. That's it.

use astor::{Router, health};

let app = Router::new()
    .get("/healthz", health::liveness)   // is the process alive?
    .get("/readyz",  health::readiness); // ready to serve traffic?

Custom readiness to gate on dependency health:

async fn readiness(_req: Request) -> Response {
    if db_pool_is_healthy().await {
        Response::text("ready")
    } else {
        Response::status(Status::ServiceUnavailable)
    }
}

Deployment

Local development

cargo run --example basic
curl http://localhost:3000/users/42

With nginx

client ──(h2/h1.1)──► nginx ──(HTTP/1.1 keep-alive pool)──► astor

nginx handles TLS, rate limiting, slow clients, and body-size limits. astor does not — by design. The configuration below is what makes that contract work.

Keep-alive pool

nginx reuses TCP connections to astor instead of opening a new one per request. astor loops on each connection until nginx closes it — it never inspects the Connection header.

upstream astor_backend {
    server 127.0.0.1:3000;

    keepalive 64;            # idle connections per worker
    keepalive_requests 1000; # recycle after N requests
    keepalive_timeout  60s;  # close idle connections after this long
}

Rule of thumb for keepalive: (expected RPS / nginx workers) × avg request duration (s). Too low → TCP churn under load. Too high → idle file descriptors accumulate.

Required location block

location / {
    # nginx forwards ALL methods by default — including unknown garbage.
    # List every method your app uses. Everything else gets 405.
    limit_except GET POST PUT PATCH DELETE OPTIONS HEAD CONNECT TRACE {
        return 405;
    }

    proxy_pass         http://astor_backend;

    # Required for keep-alive: HTTP/1.1 + clear the default "close" header.
    proxy_http_version 1.1;
    proxy_set_header   Connection  "";

    # astor reads Content-Length-framed bodies only.
    # Do not set proxy_buffering off — chunked bodies will not be read.
    proxy_buffering    on;

    # Body size, slow-client protection, and rate limiting belong here —
    # nginx enforces them before the request reaches astor.
    client_max_body_size  10m;
    client_body_timeout   30s;
    client_header_timeout 10s;
}

On Kubernetes

ingress-nginx is an nginx instance — the same rules above apply. Set the equivalent annotations on your Ingress resource.

The one astor-specific k8s requirement is terminationGracePeriodSeconds. On SIGTERM, astor stops accepting new connections and drains in-flight requests before exiting. If this value is shorter than your slowest request, k8s sends SIGKILL while requests are still running — that is not graceful shutdown.

spec:
  terminationGracePeriodSeconds: 30  # must be longer than your slowest request
  containers:
    - name: app
      image: your-registry/your-app:latest
      livenessProbe:
        httpGet: { path: /healthz, port: 3000 }
      readinessProbe:
        httpGet: { path: /readyz, port: 3000 }

A note on ordering

Everything in this codebase that can be alphabetically ordered, is. Enum variants. Function parameters. Struct fields. Imports. Table rows. Everything.

This is not a stylistic preference. It is a rule. When things are ordered, you stop thinking about where they are and start thinking about what they do. You search for Html in the ContentType enum and your eye goes straight to the Hs. You add a new variant and you know exactly where it lives. No debates, no "should this go before or after that" — alphabetical order is always the right answer and it is never wrong.

If you open a PR and something that could be alphabetically ordered is not, it will be sent back.


Contributing

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


License

MIT