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

Introduction

Volter is a production-grade, async-first web framework for Rust. It follows the same architectural principles as tower — every component is a Service or a Layer, making the framework naturally composable.

Why Volter?

Rust has several excellent web frameworks. Volter was built for teams that want:

  • No panics in request paths — deny-level lints (unwrap_used, expect_used, panic, indexing_slicing) are enforced in every library crate.
  • Tower-native composition — use any tower::Layer or tower::Service without adapters. Middleware, routing, and handlers all speak the same Service protocol.
  • Compile-time safety — wrong state type? It won’t compile. Missing extractor? It won’t compile.
  • No macros required — the core API is pure Rust. Derive macros and attribute macros are optional sugar.

Design Principles

  1. Everything is a Service. Router<S> implements tower::Service<Request>. Middleware is tower::Layer. Handlers become Services via HandlerService.

  2. Rejection is a first-class concept. Every extractor defines its own rejection type that implements IntoResponse. A missing query parameter and an invalid JSON body produce different, predictable HTTP responses.

  3. State is typed. Application state is checked at compile time. If your handler extracts State<AppConfig>, you must provide a Router<AppConfig>.

  4. Panics are caught. Wrap your router with CatchPanicLayer to turn handler panics into 500 Internal Server Error responses, keeping the server alive.

Crate Layout

Volter is organised as a set of focused crates, all re-exported through the top-level volter crate:

CratePurpose
volterMeta-crate — re-exports everything
volter-coreCore traits: Handler, FromRequest, IntoResponse, State
volter-routerRouter, MethodRouter, route construction
volter-extractExtractors: Json, Query, Path, Extension
volter-middlewareBuilt-in middleware: TraceLayer, CorsLayer, etc.
volter-wsWebSocket support
volter-macrosOptional derive and attribute macros
volter-testingTestClient for integration tests
volter-cliCLI tool for scaffolding (volter new)

Quick Start

use tokio::net::TcpListener;
use volter::{get, serve, Router};

async fn hello() -> &'static str {
    "Hello, World!"
}

#[tokio::main]
async fn main() -> Result<(), volter::BoxError> {
    let app = Router::new().route("/", get(hello));
    let listener = TcpListener::bind("0.0.0.0:3000").await?;
    serve(listener, app).await
}

Installation

Rust Toolchain

Volter requires Rust 1.79 or later. Check your version:

rustc --version

To install or update Rust, use rustup:

rustup update stable

Add Volter to Your Project

Create a new Rust project:

cargo new my-app
cd my-app

Add Volter as a dependency:

cargo add volter
cargo add tokio --features full

This adds the following to your Cargo.toml:

[dependencies]
volter = "0.1.0"
tokio = { version = "1", features = ["full"] }

You only need tokio for the async runtime and TcpListener. Volter re-exports everything else (http, tower, etc.) through the volter crate.

Verify Installation

Replace src/main.rs with:

use tokio::net::TcpListener;
use volter::{get, serve, Router};

async fn hello() -> &'static str {
    "Hello, World!"
}

#[tokio::main]
async fn main() -> Result<(), volter::BoxError> {
    let app = Router::new().route("/", get(hello));
    let listener = TcpListener::bind("0.0.0.0:3000").await?;
    eprintln!("Listening on http://0.0.0.0:3000");
    serve(listener, app).await
}

Run the project:

cargo run

Visit http://localhost:3000 — you should see Hello, World!.

Optional Features

Volter has two optional feature flags:

FeatureDefaultDescription
macrosEnabledDerive macros (FromRequestParts, FromRequest) and route attribute macros (#[get], #[post], #[put], #[patch], #[delete], #[head], #[options])
wsDisabledWebSocket support (WebSocketUpgrade, WebSocket)

Enable WebSocket support:

cargo add volter --features ws

To disable macros (not recommended unless you have a dependency conflict):

cargo add volter --no-default-features

Using the CLI

Volter includes a scaffolding CLI:

cargo install volter-cli
volter new my-api
cd my-api
cargo run

See the CLI chapter for details.

Your First Application

Let’s build a simple JSON API for managing users. You’ll learn how Volter handles routing, JSON extraction, state, and error responses — all in about 50 lines.

What We’re Building

A read-only user API with two endpoints:

  • GET /users — returns a list of users
  • GET /users/:id — returns a single user by ID

Step 1: Define the State

Application state is any value you pass to Router::with_state(). Handlers access it via the State extractor.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use volter::*;

#[derive(Clone)]
struct AppState {
    users: HashMap<u64, String>,
}
}

The state must implement Clone because the router clones it when creating boxed services at setup time.

Step 2: Define Handlers

Each handler is an async fn that takes extractors as parameters and returns anything that implements IntoResponse.

#![allow(unused)]
fn main() {
use std::sync::OnceLock;

fn default_state() -> AppState {
    let mut users = HashMap::new();
    users.insert(1, "Alice".into());
    users.insert(2, "Bob".into());
    AppState { users }
}

async fn list_users(State(state): State<AppState>) -> String {
    let names: Vec<&str> = state.users.values().map(String::as_str).collect();
    names.join(", ")
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<String, StatusCode> {
    match state.users.get(&id) {
        Some(name) => Ok(name.clone()),
        None => Err(StatusCode::NOT_FOUND),
    }
}
}

Note how get_user returns a Result: the Ok variant produces a 200 OK response, and the Err variant (a StatusCode) produces the error response directly.

Step 3: Wire It Up

#[tokio::main]
async fn main() -> Result<(), volter::BoxError> {
    let app = Router::with_state(default_state())
        .route("/users", get(list_users))
        .route("/users/:id", get(get_user));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    eprintln!("Listening on http://0.0.0.0:3000");
    serve(listener, app).await
}

Step 4: Run and Test

cargo run

In another terminal:

# List all users
curl http://localhost:3000/users
# Output: Alice, Bob

# Get user by ID
curl http://localhost:3000/users/1
# Output: Alice

# Non-existent user
curl -w "\n%{http_code}" http://localhost:3000/users/99
# Output: 404

What You Learned

  • State management — typed shared state via Router::with_state() and State<T>
  • Path parameters:id in the route pattern, extracted via Path<T>
  • Result-based error handling — returning Result<T, E> where both T and E implement IntoResponse
  • Zero macros — everything works with plain Rust functions

Next Steps

Project Structure

A typical Volter project follows standard Rust conventions. Here’s a recommended layout for a small-to-medium application:

my-api/
├── Cargo.toml
└── src/
    ├── main.rs           # Server setup and routing
    ├── handlers/         # Request handlers
    │   ├── mod.rs
    │   ├── users.rs
    │   └── posts.rs
    ├── models/           # Data types (Serialize, Deserialize)
    │   ├── mod.rs
    │   └── user.rs
    ├── state.rs          # Application state definition
    └── errors.rs         # Custom error types

The main.rs Entry Point

Keep main.rs focused on wiring — creating the router, attaching middleware, and starting the server:

use tokio::net::TcpListener;
use volter::*;

mod handlers;
mod models;
mod state;

#[tokio::main]
async fn main() -> Result<(), volter::BoxError> {
    let app = Router::with_state(state::load())
        .nest("/api/v1", api_routes())
        .layer(TraceLayer::new())
        .layer(CatchPanicLayer::new());

    let listener = TcpListener::bind("0.0.0.0:3000").await?;
    serve(listener, app).await
}

fn api_routes() -> Router<state::AppState> {
    Router::new()
        .route("/users", get(handlers::users::list))
        .route("/users/:id", get(handlers::users::get_by_id))
        .route("/posts", get(handlers::posts::list))
}

Handlers Module

Each handler file exports async fns that receive extractors:

#![allow(unused)]
fn main() {
// src/handlers/users.rs
use volter::*;
use crate::models::user::User;
use crate::state::AppState;

pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
    Json(state.users.values().cloned().collect::<Vec<User>>())
}

pub async fn get_by_id(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<User>, StatusCode> {
    state.users.get(&id).map(|u| Json(u.clone())).ok_or(StatusCode::NOT_FOUND)
}
}

Models

Models are plain structs with Serde derives:

#![allow(unused)]
fn main() {
// src/models/user.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: u64,
    pub name: String,
    pub email: String,
}
}

When to Split

Use separate files when a module has more than ~100 lines or handles multiple endpoints. For very small APIs (2-3 endpoints), a single main.rs is fine.

General Guidelines

  • One handler function per endpoint, named after the action: list_users, create_user, get_user_by_id
  • Group related handlers in the same file (users.rs for all /users/* routes)
  • Keep main.rs under 50 lines — it should be a wiring diagram, not a business-logic dump
  • Put custom IntoResponse and error types in a dedicated errors.rs module

Routing

Routing maps incoming HTTP requests to handler functions. Volter’s Router is a tower::Service — it composes with any tower middleware.

Basic Routing

Create a router and attach handlers with .route():

#![allow(unused)]
fn main() {
use volter::{get, post, put, patch, delete, head, options, Router};

let app = Router::new()
    .route("/", get(index))
    .route("/users", get(list_users))
    .route("/users", post(create_user));
}

The first argument is the path, the second is a MethodRouter created by one of the free functions: get(), post(), put(), patch(), delete(), head(), or options(). Unregistered HTTP methods produce a 405 Method Not Allowed response.

Route Patterns

Static Paths

Exact path matching — the fastest option:

#![allow(unused)]
fn main() {
.route("/about", get(about_page))
.route("/contact", get(contact_page))
}

Parameterized Paths

Named parameters start with : and are captured by the Path extractor:

#![allow(unused)]
fn main() {
.route("/users/:id", get(user_by_id))
.route("/posts/:post_id/comments/:comment_id", get(comment_by_id))
}

The segment count must match exactly. /users/42 matches /users/:id; /users/42/profile does not.

Method Routing

Each free function returns a MethodRouter that matches a single HTTP method:

#![allow(unused)]
fn main() {
use volter::{get, post, put, patch, delete, head, options, Router};

async fn list() -> &'static str { "list" }
async fn create() -> &'static str { "create" }
async fn update() -> &'static str { "updated" }
async fn remove() -> &'static str { "deleted" }

let app = Router::new()
    .route("/items", get(list))
    .route("/items", post(create))
    .route("/items/:id", put(update))
    .route("/items/:id", delete(remove));
}

A GET request to /items calls list; a POST request calls create; a PUT request to /items/42 calls update; a DELETE request calls remove; any other unregistered method returns 405.

Merging Routers

Combine independent routers with .merge():

#![allow(unused)]
fn main() {
let users_routes = Router::new()
    .route("/users", get(list_users));

let posts_routes = Router::new()
    .route("/posts", get(list_posts));

let app = users_routes.merge(posts_routes);
}

When two routers define the same path and method, the last merged wins.

Nesting Routers

Mount a router under a path prefix with .nest():

#![allow(unused)]
fn main() {
let api = Router::new()
    .route("/users", get(list_users))
    .route("/posts", get(list_posts));

let app = Router::new()
    .nest("/api/v1", api);
}

This serves list_users at GET /api/v1/users and list_posts at GET /api/v1/posts. The prefix is matched segment-by-segment: /api/v1 matches /api/v1/users but not /api/v2.

Each nested router preserves its own state and middleware.

Route Attribute Macros

As an alternative to the free-function API, you can annotate handlers with #[get("/")], #[post("/")], #[put("/")], #[patch("/")], #[delete("/")], #[head("/")], or #[options("/")]:

#![allow(unused)]
fn main() {
use volter::*;

#[get("/")]
async fn index() -> &'static str {
    "Hello!"
}

let app = Router::new().route_attr(INDEX_ROUTE, index);
}

See the Route Attribute Macros chapter for details.

Route Matching Order

  1. Post-layer static routes — O(1) hashmap lookup
  2. Post-layer parameterized routes — linear scan
  3. Post-layer nested routers — prefix strip, then delegate
  4. Pre-layer (layered) service — routes wrapped by .layer()
  5. 404 Not Found — nothing matched

State

Application state is any value shared across handlers. You provide it to the router, and handlers extract it via State<T>.

Providing State

Pass state to Router::with_state():

#![allow(unused)]
fn main() {
use volter::*;

#[derive(Clone)]
struct AppState {
    db_url: String,
}

let state = AppState { db_url: "postgres://localhost/db".into() };
let app: Router<AppState> = Router::with_state(state);
}

The state type must implement Clone because the router clones it when creating boxed services at setup time.

Extracting State

Use State<T> as a handler parameter:

#![allow(unused)]
fn main() {
async fn dashboard(State(state): State<AppState>) -> String {
    format!("Connected to {}", state.db_url)
}
}

The T in State<T> is checked at compile time. If you declare Router::with_state(AppState) but a handler expects State<OtherState>, the code won’t compile.

Why No ? Operator Needed

State<T> extraction never fails — the state type is guaranteed to match at compile time. The rejection type is Infallible, so you can use State anywhere in the extractor chain without error handling.

Stateless Routes

When no state is needed, use Router::new() (state defaults to ()):

#![allow(unused)]
fn main() {
let app: Router = Router::new()
    .route("/health", get(health_check));

async fn health_check() -> &'static str {
    "OK"
}
}

Handlers that don’t extract State work with any state type, including ().

When to Use State

  • Database connection pools
  • Configuration values
  • HTTP clients
  • Any value that should be available to every handler

For per-request values (like an authenticated user), use Extension<T> instead.

Extractors

Extractors are the mechanism handlers use to pull data out of incoming requests. Volter provides two traits:

  • FromRequestParts — extract from request metadata (URI, headers, extensions) without consuming the body. Runs first, enables early rejection.
  • FromRequest — extract from the full request, including the body. Runs after all FromRequestParts extractors.

How Extraction Works

When you write a handler with multiple parameters, Volter runs them in order:

#![allow(unused)]
fn main() {
async fn handler(
    // 1. FromRequestParts — runs first
    Query(query): Query<PageQuery>,
    // 2. FromRequestParts — runs second
    State(state): State<AppState>,
    // 3. FromRequest — runs last (may consume body)
    Json(body): Json<CreateUser>,
) -> impl IntoResponse {
    // All extractors have already succeeded by this point
}
}

The last parameter may implement either trait. All earlier parameters must implement FromRequestParts. This means metadata extractors run before the body is consumed, allowing fast rejection of invalid requests.

Available Extractors

ExtractorTraitSourceRejection
Query<T>FromRequestPartsURL query stringQueryRejection → 400
Path<T>FromRequestPartsURL path parametersPathRejection → 400
Extension<T>FromRequestPartsRequest extensionsExtensionRejection → 500
State<T>FromRequestPartsApplication stateNever fails
Json<T>FromRequestJSON bodyJsonRejection → 400/415/500

Mapping Rejections to Responses

Every extractor defines its own rejection type. Each rejection implements IntoResponse, so you can compose handlers freely:

#![allow(unused)]
fn main() {
// This handler may fail with 400 (invalid query) or 400 (invalid JSON body).
// Volter short-circuits: if Query fails, Json is never extracted.
async fn create(Query(q): Query<SearchParams>, Json(body): Json<CreateUser>) -> impl IntoResponse {
    // ...
}
}

The FromRequestParts Trait

#![allow(unused)]
fn main() {
pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;
    type Future: Future<Output = Result<Self, Self::Rejection>> + Send;
    fn from_request_parts(parts: &mut http::request::Parts, state: &S) -> Self::Future;
}
}

The FromRequest Trait

#![allow(unused)]
fn main() {
pub trait FromRequest<S, B = BoxBody>: Sized {
    type Rejection: IntoResponse;
    type Future: Future<Output = Result<Self, Self::Rejection>> + Send;
    fn from_request(req: http::Request<B>, state: &S) -> Self::Future;
}
}

Every FromRequestParts implementor also implements FromRequest (the body is split off and discarded).

JSON

The Json<T> extractor deserializes request bodies as JSON and serializes response values as JSON.

Extracting JSON Bodies

#![allow(unused)]
fn main() {
use serde::Deserialize;
use volter::*;

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

async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("Created {} (age {})", payload.name, payload.age)
}
}

Json<T> requires:

  • The request’s Content-Type header to be application/json or application/*+json
  • The body to be valid JSON that deserializes to T
  • T: DeserializeOwned + Send + 'static

Returning JSON Responses

#![allow(unused)]
fn main() {
use serde::Serialize;

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(Path(id): Path<u64>) -> Json<User> {
    Json(User { id, name: "Alice".into() })
}
}

The response has status 200 OK and Content-Type: application/json.

Error Handling

Json<T> can fail in several ways:

ConditionRejectionHTTP Status
Missing Content-TypeMissingJsonContentType415
Wrong Content-TypeUnsupportedJsonContentType415
Invalid JSON syntaxInvalidJsonBody(err)400
Body too large / connection errorBodyReadError(err)500

Handling specific rejections:

#![allow(unused)]
fn main() {
async fn create(Json(payload): Json<CreateUser>) -> Result<String, JsonRejection> {
    // JsonRejection implements IntoResponse, so return it directly
    let user = payload?;
    Ok(format!("Created {}", user.name))
}
}

JSON Echo Example

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Echo {
    message: String,
}

async fn echo(Json(payload): Json<Echo>) -> Json<Echo> {
    Json(payload)
}
}

Performance Notes

  • JSON deserialization uses serde_json under the hood
  • The body is fully buffered before deserialization (streaming JSON parsing is not supported)
  • For large payloads, consider using RequestBodyLimitLayer to reject oversized bodies early

Query

The Query<T> extractor parses URL query strings into typed structs using serde_urlencoded.

Extracting Query Parameters

#![allow(unused)]
fn main() {
use serde::Deserialize;
use volter::*;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    page: Option<u32>,
    per_page: Option<u32>,
}

async fn search(Query(params): Query<SearchParams>) -> String {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(20);
    format!("Searching for '{}' (page {page}, {per_page} per page)", params.q)
}
}

Required vs Optional Fields

Fields without #[serde(default)] are required. The extractor returns a QueryRejection (400 Bad Request) if they are missing:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct RequiredParams {
    q: String,             // required — 400 if missing
    page: Option<u32>,     // optional — None if missing
}
}

Use #[serde(default)] for fields with a fallback value:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct Pagination {
    #[serde(default = "default_page")]
    page: u32,
    #[serde(default)]
    per_page: u32,
}

fn default_page() -> u32 { 1 }
}

URL Encoding

Query<T> handles URL-encoded values automatically:

#![allow(unused)]
fn main() {
// GET /search?q=hello+world
let q = params.q; // "hello world"
}

Rejection

#![allow(unused)]
fn main() {
pub enum QueryRejection {
    InvalidQueryParams(serde_urlencoded::de::Error),  // → 400 Bad Request
}
}

The rejection implements IntoResponse, returning 400 Bad Request with a description of the parsing error.

What Query Cannot Do

  • Nested structures (use separate query structs and compose them in your handler)
  • Repeated keys as arrays (use Vec<T> for repeated query parameters, as supported by serde_urlencoded)

Multi-value Parameters

serde_urlencoded supports repeated keys mapped to Vec<T>:

#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct FilterParams {
    tags: Vec<String>,
}

// GET /items?tags=rust&tags=web&tags=api
// tags = ["rust", "web", "api"]
}

Path

The Path<T> extractor captures named parameters from route patterns.

Defining Parameterized Routes

Use :name syntax in the route pattern:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/users/:id", get(user_by_id))
    .route("/posts/:post_id/comments/:comment_id", get(comment));
}

Extracting a Single Parameter

For routes with one parameter, wrap the matching Rust type:

#![allow(unused)]
fn main() {
async fn user_by_id(Path(id): Path<u64>) -> String {
    format!("User {id}")
}
}

The router captures the :id segment as a string, and Path<u64> tries to parse it as a u64. If parsing fails, a PathRejection (400 Bad Request) is returned.

Extracting Multiple Parameters

For routes with several parameters, use a struct:

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize)]
struct CommentParams {
    post_id: u64,
    comment_id: u64,
}

async fn comment(Path(params): Path<CommentParams>) -> String {
    format!("Post {} / Comment {}", params.post_id, params.comment_id)
}
}

The field names in the struct must match the route parameter names (post_id, comment_id).

Supported Types

Any type that implements DeserializeOwned can be used:

#![allow(unused)]
fn main() {
async fn by_name(Path(name): Path<String>) -> String {
    format!("Hello {name}")
}
}
  • String — captures the raw segment
  • Numeric types (u64, i32, f64, etc.) — parses the segment
  • UUID, ULID — via Serde’s deserialize_from_str
  • Custom enums — via Serde’s deserialization

Rejection

#![allow(unused)]
fn main() {
pub enum PathRejection {
    MissingPathParams,              // → 400 Bad Request
    InvalidPathParams(serde_json::Error),  // → 400 Bad Request
}
}
  • MissingPathParams — the route pattern has parameters but the request extension was not set (internal error, should not happen in normal use)
  • InvalidPathParams — parsing failed (e.g., :id = "abc" for Path<u64>)

Important Notes

  • Route parameters are not query parameters. Use Query<T> for ?key=value
  • The parameter name in the route (:user_id) must match the struct field name (user_id)
  • Segment count must match exactly: /users/:id does not match /users/42/profile

Extension

The Extension<T> extractor reads values from the request’s extension map. Extensions are inserted by middleware or by the serve_with() modifier closure.

When to Use Extensions

  • Per-request values that aren’t known at router setup time
  • Authenticated user info set by auth middleware
  • Request IDs set by RequestIdLayer
  • Tracing correlation IDs
  • Any value that is computed per-request and consumed downstream

Do not use extensions for global application state. Use State<T> instead.

Extracting an Extension

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct CurrentUser {
    id: u64,
    name: String,
}

async fn profile(Extension(user): Extension<CurrentUser>) -> String {
    format!("Welcome, {}!", user.name)
}
}

Setting Extensions via Middleware

Middleware can insert extensions before the handler runs:

#![allow(unused)]
fn main() {
use std::task::{Context, Poll};
use tower::{Layer, Service};
use volter::{Request, Response};

#[derive(Clone)]
struct AuthLayer;

impl<S> Layer<S> for AuthLayer {
    type Service = AuthService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        AuthService { inner }
    }
}

#[derive(Clone)]
struct AuthService<S> {
    inner: S,
}

impl<S> Service<Request> for AuthService<S>
where
    S: Service<Request, Response = Response> + Clone + Send + 'static,
    S::Future: Send,
    S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
    type Response = Response;
    type Error = S::Error;
    type Future = S::Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, req: Request) -> Self::Future {
        let mut req = req;
        req.extensions_mut().insert(CurrentUser { id: 1, name: "Alice".into() });
        self.inner.call(req)
    }
}
}

Setting Extensions via serve_with

The serve_with() function accepts a closure that modifies each request before dispatch:

#![allow(unused)]
fn main() {
use volter::*;
use std::sync::atomic::{AtomicU64, Ordering};

let counter = AtomicU64::new(0);

let app = Router::new()
    .route("/", get(handler));

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;

serve_with(listener, app, move |req: &mut Request| {
    let n = counter.fetch_add(1, Ordering::SeqCst);
    req.extensions_mut().insert(RequestId(n));
})
.await
}

Rejection

#![allow(unused)]
fn main() {
pub enum ExtensionRejection {
    MissingExtension(&'static str),   // → 500 Internal Server Error
}
}

If a handler requests Extension<T> but no middleware inserted a value of type T, the rejection returns 500 Internal Server Error with a message indicating the missing type.

Built-in Extension: RequestId

The RequestIdLayer inserts a unique RequestId into every request:

#![allow(unused)]
fn main() {
use volter::*;

let app = Router::new()
    .route("/", get(handler))
    .layer(RequestIdLayer::new());

async fn handler(Extension(id): Extension<RequestId>) -> String {
    format!("Request ID: {id}")
}
}

Derive Macros

Volter provides two derive macros — FromRequestParts and FromRequest — that automatically implement the corresponding extractor traits for your types.

FromRequestParts

Derive to parse query parameters into a struct:

#![allow(unused)]
fn main() {
use serde::Deserialize;
use volter::*;

#[derive(Deserialize, FromRequestParts)]
struct SearchParams {
    q: String,
    page: Option<u32>,
}
}

This generates a FromRequestParts implementation that deserializes the URL query string via serde_urlencoded::from_str — the same parsing used by Query<T>.

Usage in Handlers

#![allow(unused)]
fn main() {
async fn search(params: SearchParams) -> String {
    format!("Searching for '{}'", params.q)
}
}

Without the derive, you would write:

#![allow(unused)]
fn main() {
async fn search(Query(params): Query<SearchParams>) -> String { ... }
}

Requirements

  • The type must implement serde::DeserializeOwned (typically via #[derive(serde::Deserialize)])
  • The type must be Send + 'static

FromRequest

Derive to parse JSON request bodies into a struct:

#![allow(unused)]
fn main() {
use serde::Deserialize;
use volter::*;

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

This generates a FromRequest implementation that clones the state, then delegates to Json<Self>::from_request.

Usage in Handlers

#![allow(unused)]
fn main() {
async fn create_user(user: CreateUser) -> Result<String, JsonRejection> {
    Ok(format!("Created {}", user.name))
}
}

Requirements

  • The type must implement serde::DeserializeOwned
  • The type must be Send + 'static
  • The state type S must implement Clone + Send + 'static (the generated code clones the state to pass across the .await boundary)

Rejection Types

DeriveRejectionHTTP Status
FromRequestPartsQueryRejection400 Bad Request
FromRequestJsonRejection400 / 415 / 500

Both rejection types implement IntoResponse, so you can return them directly from handlers.

Limitations

  • Structs only (enums are not supported)
  • No field-level attributes or custom validation
  • No support for #[from_request(via = ...)] or other configuration attributes

IntoResponse

Every handler returns a value that implements IntoResponse. The trait converts your return value into an HTTP response.

Built-in Implementations

&'static str and String

Return a string — it becomes the response body with status 200 OK:

#![allow(unused)]
fn main() {
async fn hello() -> &'static str {
    "Hello, World!"
}

async fn greet(name: String) -> String {
    format!("Hello, {name}!")
}
}

StatusCode

Return just a status code with an empty body:

#![allow(unused)]
fn main() {
async fn delete() -> StatusCode {
    StatusCode::NO_CONTENT
}
}

(StatusCode, T)

Pair a custom status code with any IntoResponse body:

#![allow(unused)]
fn main() {
async fn create() -> (StatusCode, &'static str) {
    (StatusCode::CREATED, "resource created")
}
}

Result<T, E>

Return success or error — both T and E must implement IntoResponse:

#![allow(unused)]
fn main() {
async fn get_user(id: Path<u64>) -> Result<String, StatusCode> {
    match find_user(*id) {
        Some(name) => Ok(name),
        None => Err(StatusCode::NOT_FOUND),
    }
}
}

The Ok variant produces a response from T. The Err variant produces a response from E. This pattern is the idiomatic way to handle errors in Volter.

()

Return nothing — produces 204 No Content:

#![allow(unused)]
fn main() {
async fn log_visit() { /* side effect only */ }
}

Response

Return a fully-formed http::Response<BoxBody> directly. This gives you complete control over headers, status, and body:

#![allow(unused)]
fn main() {
use volter::http::header;

async fn custom() -> Response {
    Response::builder()
        .status(200)
        .header(header::CONTENT_TYPE, "text/plain")
        .body(full_body("custom response"))
        .unwrap()
}
}

Json<T> as a Response

Any Serialize type wrapped in Json becomes a JSON response:

#![allow(unused)]
fn main() {
use serde::Serialize;

#[derive(Serialize)]
struct User {
    name: String,
}

async fn get_user() -> Json<User> {
    Json(User { name: "Alice".into() })
}
}

This produces 200 OK with Content-Type: application/json.

Custom Implementations

You can implement IntoResponse for your own types:

#![allow(unused)]
fn main() {
use volter::{IntoResponse, Response, BoxBody, full_body};

struct Html(pub String);

impl IntoResponse for Html {
    fn into_response(self) -> Response {
        Response::builder()
            .header("content-type", "text/html")
            .body(full_body(self.0))
            .unwrap()
    }
}
}

Summary Table

TypeStatusBody
&'static str200 OKThe string bytes
String200 OKThe string bytes
StatusCodeThe code itselfEmpty
(StatusCode, T)Custom statusInner value
Result<T, E>Ok or ErrDelegated
()204 No ContentEmpty
ResponsePassthroughPassthrough
Json<T>200 OKJSON bytes

Middleware

Middleware in Volter is a tower::Layer wrapping a tower::Service. Every middleware is a drop-in generic — you can use Volter’s built-in layers, tower’s own layers, or third-party tower::Layer implementations without adapters.

How Middleware Works

Middleware wraps the router (or a subset of routes) in an onion model:

#![allow(unused)]
fn main() {
Router::new()
    .route("/public", get(public_handler))   // NOT wrapped
    .layer(OuterLayer::new())                 // Wraps all pre-layer routes
    .route("/admin", get(admin_handler))      // NOT wrapped
    .layer(InnerLayer::new())                 // Inner wraps Outer+pre-layer routes
    .route("/api", get(api_handler));         // NOT wrapped
}
  • Routes registered before a .layer() call are wrapped by that layer
  • Routes registered after a .layer() call are not wrapped
  • Multiple .layer() calls compose: the last call is the outermost layer
  • Post-layer routes are tried first, then the layered (wrapped) service

Using Built-in Middleware

#![allow(unused)]
fn main() {
use volter::*;
use std::time::Duration;

let app = Router::new()
    .route("/", get(handler))
    .layer(TraceLayer::new())            // Request logging
    .layer(CatchPanicLayer::new())       // Panic recovery → 500
    .layer(TimeoutLayer::new(Duration::from_secs(30)))  // Timeout → 408
    .layer(RequestIdLayer::new());       // Unique X-Request-Id per request
}

Middleware Order Matters

Layer order follows tower’s onion model — the first .layer() call is innermost, the last is outermost. Requests travel from outer to inner; responses travel from inner to outer:

Request → TraceLayer → CatchPanicLayer → TimeoutLayer → Handler → Response
                                                                     ↓
Response ← TraceLayer ← CatchPanicLayer ← TimeoutLayer ← ← ← ← ← ← ←

Place CatchPanicLayer inside timeout and tracing so panics are caught before the error response propagates out.

Composing with Other Middleware

Because everything uses tower, any tower::Layer works directly:

#![allow(unused)]
fn main() {
use tower::limit::ConcurrencyLimitLayer;

let app = Router::new()
    .route("/", get(handler))
    .layer(ConcurrencyLimitLayer::new(100));
}

See Also

Built-in Middleware Layers

Volter provides nine built-in middleware layers, all in the volter crate.

TraceLayer

Logs every request with method, path, status code, and latency:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/", get(handler))
    .layer(TraceLayer::new());
}

Spans are emitted via tracing. Set up a subscriber in main():

cargo add tracing-subscriber --features env-filter
#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
    .with_env_filter("info")
    .init();
}

TimeoutLayer

Limits how long a request can take:

#![allow(unused)]
fn main() {
use std::time::Duration;
use volter::*;

Router::new()
    .route("/slow", get(slow_handler))
    .layer(TimeoutLayer::new(Duration::from_secs(10)));
}

Returns 408 Request Timeout if the handler exceeds the duration.

CatchPanicLayer

Catches panics from handlers and returns 500 Internal Server Error:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/", get(handler))
    .layer(CatchPanicLayer::new());
}

Without this layer, a handler panic would crash the connection.

RequestIdLayer

Assigns every request a unique RequestId and sets the X-Request-Id response header:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/", get(handler))
    .layer(RequestIdLayer::new());

async fn handler(Extension(id): Extension<RequestId>) -> String {
    format!("Request {id} received")
}
}

CorsLayer

Cross-Origin Resource Sharing — configure which origins, methods, and headers are allowed:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/api", get(handler))
    .layer(CorsLayer::permissive()); // Allow everything
}

For fine-grained control:

#![allow(unused)]
fn main() {
CorsLayer::new()
    .allow_origin("https://myapp.com")
    .allow_methods([http::Method::GET, http::Method::POST, http::Method::PUT, http::Method::DELETE])
    .allow_headers([http::header::CONTENT_TYPE])
    .allow_credentials();
}

CompressionLayer

Compresses response bodies based on the Accept-Encoding request header:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/", get(large_response))
    .layer(CompressionLayer::new()); // gzip, br, zstd, deflate
}

Choose specific algorithms:

#![allow(unused)]
fn main() {
CompressionLayer::gzip()    // gzip only
CompressionLayer::br()      // brotli only
CompressionLayer::zstd()    // zstd only
CompressionLayer::deflate() // deflate only
}

RequestBodyLimitLayer

Rejects requests whose Content-Length exceeds a threshold:

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/upload", post(upload_handler))
    .layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1 MB limit
}

Returns 413 Payload Too Large when exceeded.

ConcurrencyLimitLayer

Limits the number of concurrently executing requests. Excess requests are queued (not rejected):

#![allow(unused)]
fn main() {
use volter::*;

Router::new()
    .route("/", get(handler))
    .layer(ConcurrencyLimitLayer::new(10)); // max 10 concurrent
}

RateLimitLayer

Fixed-window rate limiter:

#![allow(unused)]
fn main() {
use std::time::Duration;
use volter::*;

Router::new()
    .route("/", get(handler))
    .layer(RateLimitLayer::new(100, Duration::from_secs(60)));
    // 100 requests per 60-second window
}

Returns 429 Too Many Requests when the limit is exceeded.

Custom Middleware

Writing custom middleware means implementing tower::Layer and tower::Service. This gives you full, type-safe access to every request and response.

Basic Pattern

A middleware consists of two types:

#![allow(unused)]
fn main() {
use std::task::{Context, Poll};
use tower::{Layer, Service};
use volter::{Request, Response};

// 1. The layer — created once, clones for every router clone
#[derive(Clone)]
struct MyLayer;

impl<S> Layer<S> for MyLayer {
    type Service = MyMiddleware<S>;
    fn layer(&self, inner: S) -> Self::Service {
        MyMiddleware { inner }
    }
}

// 2. The service — one per cloned router, calls inner after its work
#[derive(Clone)]
struct MyMiddleware<S> {
    inner: S,
}

impl<S> Service<Request> for MyMiddleware<S>
where
    S: Service<Request, Response = Response> + Clone + Send + 'static,
    S::Future: Send,
    S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
    type Response = Response;
    type Error = S::Error;
    type Future = S::Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, req: Request) -> Self::Future {
        // Pre-processing: inspect/modify the request
        eprintln!("Incoming request: {} {}", req.method(), req.uri());

        // Delegate to the inner service
        self.inner.call(req)
        // Post-processing would go in a `.map` or `async` block
    }
}
}

Modifying the Response

To inspect or modify the response, wrap the inner future:

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;

fn call(&mut self, req: Request) -> Self::Future {
    let fut = self.inner.call(req);
    Box::pin(async move {
        let response: Response = fut.await?;
        let status = response.status();
        eprintln!("Response: {status}");
        Ok(response)
    })
}
}

Injecting Extensions

Add values to the request extension map that downstream handlers can extract via Extension<T>:

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct TimingLayer;

impl<S> Layer<S> for TimingLayer {
    type Service = TimingService<S>;
    fn layer(&self, inner: S) -> Self::Service {
        TimingService { inner }
    }
}

#[derive(Clone)]
struct TimingService<S> {
    inner: S,
}

impl<S> Service<Request> for TimingService<S>
where
    S: Service<Request, Response = Response> + Clone + Send + 'static,
    S::Future: Send,
    S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
    type Response = Response;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, req: Request) -> Self::Future {
        let start = std::time::Instant::now();
        let fut = self.inner.call(req);
        Box::pin(async move {
            let response = fut.await?;
            let elapsed = start.elapsed();
            eprintln!("Handled in {:?}", elapsed);
            Ok(response)
        })
    }
}
}

Wrapping Only Specific Routes

Use .layer() to wrap routes registered before it:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/public", get(public_handler))   // No auth
    .layer(AuthLayer)                          // Auth wraps only above routes
    .route("/admin", get(admin_handler));      // No auth (post-layer)
}

This pattern lets some routes bypass middleware while others are wrapped.

Important Notes

  • The Service impl must be Clone — the router clones it at setup time
  • poll_ready should always delegate to inner.poll_ready
  • Errors must implement Into<BoxError> for compatibility
  • Prefer async blocks over manual future state machines for response modification

Testing

Volter provides a TestClient in the volter-testing crate. It lets you write integration tests against your router without starting an HTTP server.

Adding as a Dependency

volter-testing is a workspace crate. In a published project, add it as a dev-dependency:

[dev-dependencies]
volter-testing = { git = "https://github.com/anomalyco/volter" }

(It is not yet published separately or re-exported from the volter crate.)

Basic Usage

#![allow(unused)]
fn main() {
use volter::*;
use volter_testing::TestClient;

#[tokio::test]
async fn test_hello_endpoint() {
    let app = Router::new().route("/", get(hello));
    let client = TestClient::new(app);

    let response = client.get("/").send().await;
    assert_eq!(response.status(), StatusCode::OK);
}
}

Testing Status Codes

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_not_found() {
    let app = Router::new().route("/", get(hello));
    let client = TestClient::new(app);

    let response = client.get("/nonexistent").send().await;
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

Testing Response Bodies

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct UserResponse {
    id: u64,
    name: String,
}

#[tokio::test]
async fn test_json_response() {
    let app = Router::new().route("/user", get(user_handler));
    let client = TestClient::new(app);

    let response = client.get("/user").send().await;
    assert_eq!(response.status(), StatusCode::OK);

    let body: serde_json::Value = response.json().await.unwrap();
    assert_eq!(body["name"], "Alice");
}
}

Sending Request Bodies

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize)]
struct CreateResponse {
    id: u64,
}

#[tokio::test]
async fn test_create_user() {
    let app = Router::new().route("/users", post(create_user));
    let client = TestClient::new(app);

    let response = client
        .post("/users")
        .json(&serde_json::json!({"name": "Alice"}))
        .send()
        .await;

    assert_eq!(response.status(), StatusCode::CREATED);

    let body: CreateResponse = response.json().await.unwrap();
    assert!(body.id > 0);
}
}

Testing Headers

Set custom headers on the request and check headers on the response:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_custom_header() {
    let app = Router::new().route("/", get(echo_header));
    let client = TestClient::new(app);

    let response = client
        .get("/")
        .header(http::header::AUTHORIZATION, "Bearer token123".parse().unwrap())
        .send()
        .await;

    assert_eq!(
        response.headers().get(http::header::CONTENT_TYPE),
        Some(&http::HeaderValue::from_static("application/json"))
    );
}
}

Testing with State

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState {
    counter: u64,
}

#[tokio::test]
async fn test_with_state() {
    let app = Router::with_state(AppState { counter: 42 })
        .route("/", get(handler));
    let client = TestClient::new(app);
    // ...
}
}

Testing Middleware

Test that middleware behaves correctly:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_timeout() {
    use std::time::Duration;

    async fn slow_handler() -> &'static str {
        tokio::time::sleep(Duration::from_secs(10)).await;
        "done"
    }

    let app = Router::new()
        .route("/slow", get(slow_handler))
        .layer(TimeoutLayer::new(Duration::from_millis(10)));

    let mut client = TestClient::new(app);
    let response = client.get("/slow").send().await;
    assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
}
}

The Testing API

TestClient

#![allow(unused)]
fn main() {
impl<S: Clone + Send + 'static> TestClient<S> {
    pub fn new(router: Router<S>) -> Self;
    pub fn get(&self, path: &str) -> TestRequestBuilder<S>;
    pub fn post(&self, path: &str) -> TestRequestBuilder<S>;
    pub fn request(&self, method: Method, path: &str) -> TestRequestBuilder<S>;
}
}

TestRequestBuilder

#![allow(unused)]
fn main() {
impl TestRequestBuilder {
    pub fn header(self, name: HeaderName, value: HeaderValue) -> Self;
    pub fn json<T: Serialize>(self, value: &T) -> Self;
    pub fn body(self, body: impl Into<Bytes>) -> Self;
    pub async fn send(self) -> TestResponse;
}
}

TestResponse

#![allow(unused)]
fn main() {
impl TestResponse {
    pub fn status(&self) -> StatusCode;
    pub fn headers(&self) -> &HeaderMap;
    pub async fn bytes(self) -> Result<Bytes, BodyError>;
    pub async fn text(self) -> Result<String, BodyError>;
    pub async fn json<T: DeserializeOwned>(self) -> Result<T, BodyError>;
}
}

WebSockets

Volter supports WebSocket upgrades via the WebSocketUpgrade extractor.

Enabling the Feature

WebSocket support is gated behind the ws feature flag (not enabled by default):

cargo add volter --features ws

Basic Echo Server

#![allow(unused)]
fn main() {
use volter::*;
use volter::ws::{Message, WebSocketUpgrade};

async fn echo(ws: WebSocketUpgrade) -> impl IntoResponse {
    ws.on_upgrade(|mut socket| async move {
        while let Some(Ok(msg)) = socket.recv().await {
            if socket.send(msg).await.is_err() {
                break;
            }
        }
    })
}
}

The handler receives a WebSocketUpgrade extractor. Calling on_upgrade(callback) immediately returns a 101 Switching Protocols response, and the callback runs in a spawned tokio task once hyper completes the upgrade.

The WebSocket Upgrade Flow

  1. The handler receives a WebSocketUpgrade parameter
  2. Volter checks if the request has the Upgrade: websocket header
  3. If yes, on_upgrade() produces a 101 Switching Protocols response
  4. The callback receives a WebSocket with recv() / send() methods
  5. If the Upgrade header is missing, 426 Upgrade Required is returned
  6. If Sec-WebSocket-Key is missing, 400 Bad Request is returned

Sending and Receiving Messages

#![allow(unused)]
fn main() {
use volter::ws::{Message, WebSocketUpgrade};

async fn handle_ws(ws: WebSocketUpgrade) -> impl IntoResponse {
    ws.on_upgrade(|mut socket| async move {
        // Send a text message
        let _ = socket.send(Message::Text("Welcome!".into())).await;

        // Read messages
        while let Some(Ok(msg)) = socket.recv().await {
            match msg {
                Message::Text(text) => {
                    let _ = socket.send(Message::Text(format!("Echo: {text}"))).await;
                }
                Message::Binary(data) => {
                    let _ = socket.send(Message::Binary(data)).await;
                }
                Message::Ping(_) => {
                    let _ = socket.send(Message::Pong(vec![])).await;
                }
                Message::Close(frame) => {
                    let _ = socket.send(Message::Close(frame)).await;
                    break;
                }
                _ => {}
            }
        }
    })
}
}

WebSocket with State

#![allow(unused)]
fn main() {
use volter::*;
use volter::ws::WebSocketUpgrade;

#[derive(Clone)]
struct AppState {
    max_message_size: usize,
}

async fn ws_handler(
    State(state): State<AppState>,
    ws: WebSocketUpgrade,
) -> impl IntoResponse {
    ws.on_upgrade(move |mut socket| async move {
        // state is available here
    })
}
}

WebSocket with Query Parameters

#![allow(unused)]
fn main() {
use serde::Deserialize;
use volter::*;
use volter::ws::WebSocketUpgrade;

#[derive(Deserialize)]
struct WsParams {
    room: String,
    token: String,
}

async fn chat(
    Query(params): Query<WsParams>,
    ws: WebSocketUpgrade,
) -> impl IntoResponse {
    // Validate token and join room
    ws.on_upgrade(move |mut socket| async move {
        // ...
    })
}
}

Important Notes

  • WebSocketUpgrade implements FromRequestParts, so it runs before the body is consumed

  • The on_upgrade callback runs in a spawned tokio task — state must be 'static if captured by the closure

  • See the websocket example for a complete, runnable server:

    cargo run -p websocket
    

Route Attribute Macros

The #[get], #[post], #[put], #[patch], #[delete], #[head], and #[options] attribute macros provide a shorthand for defining routes directly on your handler functions.

Basic Usage

#![allow(unused)]
fn main() {
use volter::*;

#[get("/")]
async fn index() -> &'static str {
    "Hello, World!"
}

#[post("/users")]
async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("Created user")
}

let app: Router = Router::new()
    .route_attr(INDEX_ROUTE, index)
    .route_attr(CREATE_USER_ROUTE, create_user);
}

Each attribute macro:

  1. Preserves the original function (visibility, docs, and signature are unchanged)
  2. Generates a const with the path, e.g. INDEX_ROUTE, of type RouteAttr
  3. The const is passed to Router::route_attr() to register the route

Generated Names

The const is named by uppercasing the function name and appending _ROUTE:

FunctionGenerated const
fn index()INDEX_ROUTE
fn create_user()CREATE_USER_ROUTE
fn get_user_by_id()GET_USER_BY_ID_ROUTE

Route Parameters

Attribute macros work with path parameters too:

#![allow(unused)]
fn main() {
#[get("/users/:id")]
async fn get_user(Path(id): Path<u64>) -> String {
    format!("User {id}")
}
}

State

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState { db_url: String }

#[get("/dashboard")]
async fn dashboard(State(state): State<AppState>) -> String {
    format!("DB: {}", state.db_url)
}
}

Type Safety

The generated RouteAttr stores only the path and HTTP method — it does not store the handler. The handler type is inferred when you call route_attr(ATTR, handler), so you get full type checking at registration time rather than at macro expansion time.

Compared to Inline Routing

#![allow(unused)]
fn main() {
// Attribute macro style:
#[get("/")]
async fn home() -> &'static str { "home" }

Router::new().route_attr(HOME_ROUTE, home)

// Inline style (equivalent):
async fn home() -> &'static str { "home" }

Router::new().route("/", get(home))
}

The attribute macro style keeps the route pattern next to the function, which can be easier to maintain as the number of handlers grows.

Available Macros

MacroHTTP method
#[get(path)]GET
#[post(path)]POST
#[put(path)]PUT
#[patch(path)]PATCH
#[delete(path)]DELETE
#[head(path)]HEAD
#[options(path)]OPTIONS

Limitations

  • Arguments must be a single string literal (e.g. #[get("/path")])
  • The function must be async
  • Works best with Router::new() — stateful routers require the state type to be inferred from route_attr calls

CLI

Volter includes a CLI tool for scaffolding new projects.

Installation

cargo install volter-cli

Commands

volter new

Create a new Volter project:

volter new my-app
cd my-app
cargo run

This creates a minimal project structure:

my-app/
├── Cargo.toml
└── src/
    └── main.rs

With a functional “Hello, World!” server already in place.

volter run (Planned)

Starts the project with optional file-watching for hot reload. Not yet implemented.

Note

The CLI is optional. You can build Volter applications with just cargo init and adding volter to your Cargo.toml manually.

Examples

The Volter repository includes several runnable workspace member packages in the examples/ directory. Run any of them from the workspace root with cargo run -p <name>.

Basic Examples

Hello World

cargo run -p hello-world

A minimal server with a single route returning “Hello, World!”.

Path Parameters

cargo run -p path-params

Path parameters with Path<T> for single and multi-parameter routes.

Query Parameters

cargo run -p query-params

URL query parameter parsing with Query<T>.

JSON

cargo run -p json-example

JSON request body deserialization and JSON response serialization.

Merge

cargo run -p merge

Combining independent routers with Router::merge().

Nesting

cargo run -p nesting

Mounting routers under a path prefix with Router::nest().

Extensions

cargo run -p extensions-example

Request extensions set by middleware and consumed by handlers.

Multiple Extractors

cargo run -p multi-extractors-example

Handlers using two extractors (e.g. State + Query).

Derive Macros

Derive Extractors

cargo run -p derive-extractors

Using #[derive(FromRequestParts)] and #[derive(FromRequest)] on your types.

Route Attribute Macros

Route Macros

cargo run -p route-macros

Using #[get], #[post], #[put], #[patch], #[delete], #[head], and #[options] with Router::route_attr(). Demonstrates a full REST-style API.

WebSocket

cargo run -p websocket

A basic WebSocket echo server.

Middleware

Catch Panic

cargo run -p catch-panic-example

Panic recovery with CatchPanicLayer.

Timeout

cargo run -p timeout-example

Request timeouts with TimeoutLayer.

Tracing

cargo run -p tracing-example

Request logging with TraceLayer.

CORS

cargo run -p cors-example

Cross-Origin Resource Sharing with CorsLayer.

Compression

cargo run -p compression-example

Response compression with CompressionLayer.

Body Limit

cargo run -p body-limit-example

Request body size limiting with RequestBodyLimitLayer.

Concurrency Limit

cargo run -p concurrency-limit-example

Limiting concurrent requests with ConcurrencyLimitLayer.

Rate Limit

cargo run -p rate-limit-example

Fixed-window rate limiting with RateLimitLayer.

Request ID

cargo run -p request-id-example

Unique per-request IDs with RequestIdLayer.

Custom Middleware

cargo run -p middleware-example

Implementing tower::Layer and tower::Service for custom middleware.

Running Examples

All examples start a server on http://127.0.0.1:3000 by default. Check the example source for the exact port and available routes.

Performance

Benchmark results from the Criterion benchmark suite in crates/volter/benches/router.rs.

These numbers are provided for transparency and may vary depending on hardware, operating system, compiler version, and runtime environment.

Test environment

AttributeValue
MachineMacBook Pro M3 Max
Memory64 GB RAM
Buildcargo bench (release)
Benchmark toolCriterion

Methodology

Each benchmark measures the wall-clock time of a single request dispatch through the framework. No real TCP socket is bound — requests are constructed in memory and dispatched directly through the router’s tower::Service::call implementation.

  • Sample size: 100 measurements per benchmark
  • Measurement time: 5 seconds per benchmark
  • Warm-up: 2 seconds before measurement begins
  • Runtime: tokio::runtime::Runtime::block_on for every iteration

Results

BenchmarkMedianDescription
static_route312.93 nsSingle static route, no extractors
path_params566.83 ns/:id pattern match + Path<i32>
query_extraction523.72 nsQuery string deserialization (Query<T>)
json_extraction610.58 nsJSON body deserialization (Json<T>)
multi_extractor792.91 nsState<App> + Path<i32> + Query<T>
middleware/bare310.01 nsPlain route, no middleware
middleware/with_layers1,291.90 ns4 middleware layers
full_pipeline338.71 nsEnd-to-end via TestClient

Observations

Router dispatch — Static routes cost ~313 ns (a hash-map lookup). Adding a path parameter (/:id) raises this to ~567 ns due to the linear scan over registered parameterised routes.

ExtractorsQuery<T> (~524 ns) and Json<T> (~611 ns) are dominated by serde deserialization. The framework overhead beyond serde is minimal (extension insertion for path params, body buffering for JSON).

Middleware — A stack of four layers (RequestId, Trace, Timeout, CatchPanic) adds ~980 ns over a bare route, or roughly 250 ns per layer on this hardware. Each layer adds a BoxCloneService wrapper and a small amount of per-request bookkeeping.

End-to-end — The TestClient pipeline (which clones the router and constructs a fresh request) adds ~26 ns compared to a direct Service::call.

Running locally

cargo bench -p volter

HTML reports with distribution plots are written to target/criterion/report/index.html.

CI

Benchmarks are tracked across commits to catch regressions. For the CI workflow, see TOOLS.md → “Benchmarking”.

FAQ

General

What is Volter?

Volter is a Rust web framework built on hyper, tokio, and tower. It uses an extractor-based architecture similar to Axum, with a focus on compile-time safety, composability, and ergonomics.

Why build another web framework?

Volter was designed as a learning and experimentation project to explore web framework architecture in Rust. It prioritizes clear code, minimal dependencies, and a developer experience that feels natural to Rust programmers.

Is Volter production-ready?

Volter is a hobby and educational project. While it works well for real applications, it does not have the ecosystem maturity of Axum or Actix-Web.

Routing

Can I nest routers?

Yes, use Router::nest():

#![allow(unused)]
fn main() {
let api = Router::new()
    .route("/users", get(list_users));

let app = Router::new()
    .nest("/api", api);
}

Extractors

Can I use multiple extractors in one handler?

Yes, use a tuple:

#![allow(unused)]
fn main() {
async fn handler(
    State(state): State<AppState>,
    Query(params): Query<SearchParams>,
    Json(body): Json<CreateUser>,
) -> impl IntoResponse { ... }
}

Why doesn’t State<T> return a Result?

State<T> extraction is guaranteed to succeed at compile time — the state type is checked when the router is constructed. The rejection type is Infallible.

Can I create custom extractors?

Yes. Implement FromRequestParts or FromRequest for your type, or use the derive macros.

Macros

Do I need macros to use Volter?

No. All macros are optional. The core API (Router::new().route("/", get(handler))) works without any macros.

Why do #[get] and #[post] generate a const instead of registering directly?

The const stores only the path and HTTP method, not the handler type. This avoids type inference issues with impl Future types in const generics and keeps macro expansion simple and robust.

Performance

How does Volter compare to Axum?

Volter is built on the same hyper/tokio/tower stack as Axum, so raw request dispatch throughput is comparable. See Performance for the current Criterion benchmark results.

Does Volter support streaming?

Body streaming works via hyper’s body API. Streaming JSON parsing is not built in — the body is fully buffered before deserialization.

Compatibility

What Rust version do I need?

Volter requires Rust 1.79 or later.

Does Volter work with WASM?

No. Volter depends on tokio and hyper, which require OS-level I/O.

Can I use Volter with other tower middleware?

Yes. Any tower::Layer works directly with Router::layer().

Architecture

Crate Layout

Volter is organized as a monorepo with multiple crates:

volter/                          # Umbrella crate — re-exports everything
├── crates/
│   ├── volter-core/             # Core traits: Handler, FromRequest, IntoResponse
│   ├── volter-extract/          # Extractors: Json, Query, Path, Extension
│   ├── volter-router/           # Router, MethodRouter, RouteAttr
│   ├── volter-middleware/       # Built-in middleware layers
│   ├── volter-ws/               # WebSocket support
│   ├── volter-macros/           # Derive and attribute macros
│   ├── volter-testing/          # TestClient for integration tests
│   └── volter-cli/              # CLI tool for scaffolding
├── examples/
└── docs/

Core Flow

HTTP Request
    │
    ▼
hyper::Server ──► Router ──► MethodRouter
                                  │
                           ┌──────┴──────┐
                           ▼              ▼
                      Handler A      Handler B
                           │              │
                      Extractor       Extractor
                      Chain           Chain
                           │              │
                           ▼              ▼
                     IntoResponse    IntoResponse
                           │              │
                           └──────┬──────┘
                                  ▼
                            HTTP Response
  1. hyper accepts the TCP connection and parses the HTTP request
  2. Router matches the path against registered routes
  3. MethodRouter checks the HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
  4. Handler is called, which runs the extractor chain
  5. Extractors pull data from the request (path, query, body, state, etc.)
  6. Handler function runs with extracted parameters
  7. Return value is converted via IntoResponse into an HTTP response

Key Traits

FromRequestParts<S>

Defines extraction from the request’s head (URI, method, headers, extensions) without consuming the body. Used by Path, Query, State, Extension.

#![allow(unused)]
fn main() {
pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;
    type Future: Future<Output = Result<Self, Self::Rejection>> + Send;
    fn from_request_parts(parts: &mut Parts, state: &S) -> Self::Future;
}
}

FromRequest<S, B>

Defines extraction that consumes the request body. Used by Json.

#![allow(unused)]
fn main() {
pub trait FromRequest<S, B = BoxBody>: Sized {
    type Rejection: IntoResponse;
    type Future: Future<Output = Result<Self, Self::Rejection>> + Send;
    fn from_request(req: Request<B>, state: &S) -> Self::Future;
}
}

Handler<T, S>

Converts a handler function into a tower Service. The blanket impl for functions with extractor parameters handles chaining extractors.

IntoResponse

Converts any response type into Response<BoxBody>. Implemented for common types: &'static str, String, Json<T>, StatusCode, Result<T, E>.

Middleware

Middleware wraps the router in a tower service stack. Each Router::layer() call adds an outer wrapper. The onion model means:

  • Routes before .layer() are wrapped
  • Routes after .layer() are not wrapped
  • Later layers wrap earlier layers

Stateless vs Stateful

  • Router::new() — state type defaults to ()
  • Router::with_state(state) — state type is inferred from the value
  • Handlers can extract State<T> where T must match the router’s state type

The state is cloned at service setup time and stored in the router’s tower service, available to every handler and middleware.

Contributing

Getting Started

  1. Clone the repository:

    git clone https://github.com/aliwert/volter
    cd volter
    
  2. Build all crates:

    cargo build --workspace --all-features
    
  3. Run tests:

    cargo test --workspace --all-features
    

Code Requirements

All code must pass the following checks:

cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-features

Deny Rules (RULES.md)

The project has strict deny rules enforced via Clippy lint attributes:

  • clippy::unwrap_used — use ? or match instead of .unwrap()
  • clippy::expect_used — same as unwrap
  • clippy::panic — no panics in production code
  • clippy::indexing_slicing — use .get() instead of direct indexing
  • unsafe — not allowed in production code

These are enforced at the crate level in lib.rs and main.rs.

MSRV

The minimum supported Rust version is 1.79. All code must compile with this version. Use cargo +1.79 check --workspace --all-features to verify.

Pull Request Process

  1. Create a feature branch from main
  2. Make your changes
  3. Add tests for new functionality
  4. Run all checks (fmt, clippy, test)
  5. Verify MSRV compatibility
  6. Open a pull request

Architecture

See Architecture for the crate layout and core design patterns.

Adding a New Feature

  1. Check that the feature fits Volter’s scope (web framework primitives)
  2. Follow existing patterns in the relevant crate
  3. Add public items with rustdoc documentation
  4. Add integration tests in the volter crate’s tests/ directory
  5. Add an example if the feature introduces a new user-facing concept

Code of Conduct

Be respectful, constructive, and patient. This is a learning project — not everyone has the same level of experience.