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::Layerortower::Servicewithout adapters. Middleware, routing, and handlers all speak the sameServiceprotocol. - 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
-
Everything is a Service.
Router<S>implementstower::Service<Request>. Middleware istower::Layer. Handlers becomeServices viaHandlerService. -
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. -
State is typed. Application state is checked at compile time. If your handler extracts
State<AppConfig>, you must provide aRouter<AppConfig>. -
Panics are caught. Wrap your router with
CatchPanicLayerto turn handler panics into500 Internal Server Errorresponses, keeping the server alive.
Crate Layout
Volter is organised as a set of focused crates, all re-exported through the
top-level volter crate:
| Crate | Purpose |
|---|---|
volter | Meta-crate — re-exports everything |
volter-core | Core traits: Handler, FromRequest, IntoResponse, State |
volter-router | Router, MethodRouter, route construction |
volter-extract | Extractors: Json, Query, Path, Extension |
volter-middleware | Built-in middleware: TraceLayer, CorsLayer, etc. |
volter-ws | WebSocket support |
volter-macros | Optional derive and attribute macros |
volter-testing | TestClient for integration tests |
volter-cli | CLI 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:
| Feature | Default | Description |
|---|---|---|
macros | Enabled | Derive macros (FromRequestParts, FromRequest) and route attribute macros (#[get], #[post], #[put], #[patch], #[delete], #[head], #[options]) |
ws | Disabled | WebSocket 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 usersGET /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()andState<T> - Path parameters —
:idin the route pattern, extracted viaPath<T> - Result-based error handling — returning
Result<T, E>where bothTandEimplementIntoResponse - Zero macros — everything works with plain Rust functions
Next Steps
- Learn about Routing in detail
- See how to extract JSON bodies
- Read about middleware for logging, CORS, etc.
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.rsfor all/users/*routes) - Keep
main.rsunder 50 lines — it should be a wiring diagram, not a business-logic dump - Put custom
IntoResponseand error types in a dedicatederrors.rsmodule
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
- Post-layer static routes — O(1) hashmap lookup
- Post-layer parameterized routes — linear scan
- Post-layer nested routers — prefix strip, then delegate
- Pre-layer (layered) service — routes wrapped by
.layer() - 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 allFromRequestPartsextractors.
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
| Extractor | Trait | Source | Rejection |
|---|---|---|---|
Query<T> | FromRequestParts | URL query string | QueryRejection → 400 |
Path<T> | FromRequestParts | URL path parameters | PathRejection → 400 |
Extension<T> | FromRequestParts | Request extensions | ExtensionRejection → 500 |
State<T> | FromRequestParts | Application state | Never fails |
Json<T> | FromRequest | JSON body | JsonRejection → 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-Typeheader to beapplication/jsonorapplication/*+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:
| Condition | Rejection | HTTP Status |
|---|---|---|
Missing Content-Type | MissingJsonContentType | 415 |
Wrong Content-Type | UnsupportedJsonContentType | 415 |
| Invalid JSON syntax | InvalidJsonBody(err) | 400 |
| Body too large / connection error | BodyReadError(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_jsonunder the hood - The body is fully buffered before deserialization (streaming JSON parsing is not supported)
- For large payloads, consider using
RequestBodyLimitLayerto 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 byserde_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"forPath<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/:iddoes 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
Smust implementClone + Send + 'static(the generated code clones the state to pass across the.awaitboundary)
Rejection Types
| Derive | Rejection | HTTP Status |
|---|---|---|
FromRequestParts | QueryRejection | 400 Bad Request |
FromRequest | JsonRejection | 400 / 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
| Type | Status | Body |
|---|---|---|
&'static str | 200 OK | The string bytes |
String | 200 OK | The string bytes |
StatusCode | The code itself | Empty |
(StatusCode, T) | Custom status | Inner value |
Result<T, E> | Ok or Err | Delegated |
() | 204 No Content | Empty |
Response | Passthrough | Passthrough |
Json<T> | 200 OK | JSON 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 Layers — details of each Volter middleware
- Custom Middleware — writing your own middleware
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
Serviceimpl must beClone— the router clones it at setup time poll_readyshould always delegate toinner.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
- The handler receives a
WebSocketUpgradeparameter - Volter checks if the request has the
Upgrade: websocketheader - If yes,
on_upgrade()produces a101 Switching Protocolsresponse - The callback receives a
WebSocketwithrecv()/send()methods - If the
Upgradeheader is missing,426 Upgrade Requiredis returned - If
Sec-WebSocket-Keyis missing,400 Bad Requestis 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
-
WebSocketUpgradeimplementsFromRequestParts, so it runs before the body is consumed -
The
on_upgradecallback runs in a spawned tokio task — state must be'staticif captured by the closure -
See the
websocketexample 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:
- Preserves the original function (visibility, docs, and signature are unchanged)
- Generates a
constwith the path, e.g.INDEX_ROUTE, of typeRouteAttr - 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:
| Function | Generated 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
| Macro | HTTP 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 fromroute_attrcalls
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
| Attribute | Value |
|---|---|
| Machine | MacBook Pro M3 Max |
| Memory | 64 GB RAM |
| Build | cargo bench (release) |
| Benchmark tool | Criterion |
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_onfor every iteration
Results
| Benchmark | Median | Description |
|---|---|---|
static_route | 312.93 ns | Single static route, no extractors |
path_params | 566.83 ns | /:id pattern match + Path<i32> |
query_extraction | 523.72 ns | Query string deserialization (Query<T>) |
json_extraction | 610.58 ns | JSON body deserialization (Json<T>) |
multi_extractor | 792.91 ns | State<App> + Path<i32> + Query<T> |
middleware/bare | 310.01 ns | Plain route, no middleware |
middleware/with_layers | 1,291.90 ns | 4 middleware layers |
full_pipeline | 338.71 ns | End-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.
Extractors — Query<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
- hyper accepts the TCP connection and parses the HTTP request
- Router matches the path against registered routes
- MethodRouter checks the HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
- Handler is called, which runs the extractor chain
- Extractors pull data from the request (path, query, body, state, etc.)
- Handler function runs with extracted parameters
- 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>whereTmust 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
-
Clone the repository:
git clone https://github.com/aliwert/volter cd volter -
Build all crates:
cargo build --workspace --all-features -
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 unwrapclippy::panic— no panics in production codeclippy::indexing_slicing— use.get()instead of direct indexingunsafe— 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
- Create a feature branch from
main - Make your changes
- Add tests for new functionality
- Run all checks (fmt, clippy, test)
- Verify MSRV compatibility
- Open a pull request
Architecture
See Architecture for the crate layout and core design patterns.
Adding a New Feature
- Check that the feature fits Volter’s scope (web framework primitives)
- Follow existing patterns in the relevant crate
- Add public items with rustdoc documentation
- Add integration tests in the
voltercrate’stests/directory - 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.