Comprehensive API Development Guide
From "Hello World" to production-hardened microservices using Chopin's Shared-Nothing architecture.
1. Foundation & Setup
Welcome to the official developer guide for the Chopin HTTP Framework. Chopin
requires Rust 1.75+ (stable). It's optimized for UNIX-like environments (Linux
epoll and macOS kqueue).
Environment Configuration
Install the chopin-cli, your central tool for generating and managing projects:
cargo install --path crates/chopin-cli
Project Initialization
chopin new my_api
cd my_api
chopin dev
The Big Picture (Architecture)
Chopin relies on a Shared-Nothing threaded model:
- Atomic Listener Binding: Using
SO_REUSEPORT, every single thread binds independently to the same port. - Worker Concurrency: Each logical CPU core gets a dedicated thread running an Edge-Triggered event loop.
Boilerplate Breakdown
When you run chopin new, you get a structured codebase:
-
src/main.rs: Discovers and mounts all routes viainventory. -
src/apps/: Domain-driven modules for your API.
2. Core Routing & Handlers
Chopin uses declarative attribute macros for request mapping. Handlers take a Context
and return a Response.
Basic Mapping
use chopin_core::{Context, Response};
use chopin_macros::get;
#[get("/ping")]
fn ping(_ctx: Context) -> Response {
Response::text("pong")
}
All HTTP Methods
use chopin_macros::{get, post, put, delete};
#[get("/users")] fn list_users(ctx: Context) -> Response { ... }
#[post("/users")] fn create_user(ctx: Context) -> Response { ... }
#[put("/users/:id")] fn update_user(ctx: Context) -> Response { ... }
#[delete("/users/:id")] fn delete_user(ctx: Context) -> Response { ... }
Path Parameters, Headers & Query
#[get("/users/:id")]
fn get_user(ctx: Context) -> Response {
let user_id = ctx.param("id").unwrap_or("0"); // :id segment
let auth = ctx.header("authorization"); // case-insensitive
let qs = ctx.req.query; // raw query string &str
Response::text(format!("User: {}", user_id))
}
Wildcard Paths
#[get("/assets/*path")]
fn serve_static(ctx: Context) -> Response {
let rel = ctx.param("path").unwrap_or("");
Response::file(&format!("public/{}", rel))
}
Manual Router Registration
use chopin_core::router::Router;
let mut router = Router::new();
router.get("/ping", ping_handler);
router.post("/users", create_user_handler);
router.finalize(); // Must call before serving
Type-Safe JSON Responses
Chopin uses the Schema-JIT engine (kowito-json) for ultra-fast serialization.
use kowito_json::serialize::Serialize;
#[derive(Serialize)]
struct UserResponse { id: i32, username: String }
#[get("/user")]
fn get_user(ctx: Context) -> Response {
let payload = UserResponse { id: 1, username: "virtuoso".into() };
ctx.json(&payload) // shorthand for Response::json(&payload)
}
3. Extractors
Chopin provides type-safe extractors for request bodies and query strings via
ctx.extract::<T>(). On failure they return a pre-built
400 Bad Request you can return directly.
JSON Body — Json<T>
use serde::Deserialize;
use chopin_core::extract::Json;
#[derive(Deserialize)]
struct NewItem { name: String, price: i32 }
#[post("/items")]
fn create_item(ctx: Context) -> Response {
let Json(body) = match ctx.extract::<Json<NewItem>>() {
Ok(j) => j,
Err(e) => return e, // 400 for malformed JSON
};
// body.name and body.price are now available
Response::new(201)
}
Query String — Query<T>
use serde::Deserialize;
use chopin_core::extract::Query;
#[derive(Deserialize)]
struct Pagination { page: u32, limit: Option<u32> }
#[get("/users")]
fn list_users(ctx: Context) -> Response {
let Query(paging) = match ctx.extract::<Query<Pagination>>() {
Ok(q) => q,
Err(e) => return e,
};
Response::text(format!("Page {}", paging.page))
}
Raw Body Bytes
#[post("/raw")]
fn raw_handler(ctx: Context) -> Response {
let body: &[u8] = ctx.req.body; // zero-copy slice into the read buffer
Response::text(format!("{} bytes received", body.len()))
}
4. The Middleware Pipeline
Middlewares are pure functions with signature
fn(Context, BoxedHandler) -> Response. They intercept the
request, call next(ctx) when ready, and can modify the response.
Chains are pre-composed at startup via Router::finalize(). The hot
path calls one pre-built Arc<dyn Fn> — zero allocations per request.
Timing Interceptor
use chopin_core::router::BoxedHandler;
fn timing_middleware(ctx: Context, next: BoxedHandler) -> Response {
let start = std::time::Instant::now();
let response = next(ctx);
response.with_header("X-Runtime-Us", start.elapsed().as_micros())
}
// Global: applies to all routes
router.layer(timing_middleware);
// Scoped: applies to /api/* only
router.layer_path("/api", timing_middleware);
CORS Middleware
fn cors_middleware(ctx: Context, next: BoxedHandler) -> Response {
next(ctx)
.with_header("Access-Control-Allow-Origin", "*")
.with_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
.with_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
Role-Based Auth (JWT)
use chopin_auth::{require_role_middleware, Role};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, PartialEq)]
enum UserRole { Admin, User }
#[derive(Serialize, Deserialize)]
struct MyClaims {
sub: String,
role: UserRole,
}
impl MyClaims {
fn has_role(&self, required: &UserRole) -> bool { &self.role == required }
}
// Generates a static zero-allocation admin_only() wrapper function
require_role_middleware!(admin_only, MyClaims, UserRole::Admin, MyClaims::has_role);
router.get("/admin/dashboard", admin_only(dashboard_handler));
JWT Token Management
use chopin_auth::JwtManager;
// Sign a new token
let token = JwtManager::sign(&claims, "my_secret", 3600).unwrap();
// Verify & decode a token
let claims: MyClaims = JwtManager::verify(&token, "my_secret").unwrap();
5. Data & Persistence
Shared-Nothing persistence means avoid global mutexes on pools.
Worker-Local Pooling
thread_local! {
pub static DB: RefCell = RefCell::new(
PgPool::connect(PgConfig::from_url("...").unwrap(), 10).expect("DB panic")
);
}
ORM Usage
#[derive(Model)]
struct Product {
#[model(primary_key)]
id: i32,
name: String,
}
#[get("/products")]
fn list_products(ctx: Context) -> Response {
DB.with(|db| {
let mut pool = db.borrow_mut();
let products = QueryBuilder::::new().all(&mut *pool).unwrap();
ctx.json(&products)
})
}
6. Advanced Logic
Zero-Copy File Serving
Serve files directly from disk using the platform sendfile syscall — bytes never pass
through user space. Content-Type is auto-detected from the file extension.
#[get("/downloads/*path")]
fn serve_file(ctx: Context) -> Response {
let rel = ctx.param("path").unwrap_or("");
Response::file(&format!("public/{}", rel))
// Returns 404 automatically if file doesn't exist
}
For custom byte ranges:
use chopin_core::http::OwnedFd;
use chopin_core::syscalls::{open_file_readonly, file_size};
fn serve_range(ctx: Context) -> Response {
let fd = open_file_readonly("/data/video.mp4").unwrap();
let size = file_size(fd).unwrap();
Response::sendfile(OwnedFd::new(fd), 0, size, "video/mp4")
}
Streaming Multipart Uploads
Chopin's Multipart parser iterates through body bytes without massive buffering.
ctx.multipart() automatically extracts the boundary from the
Content-Type header.
#[post("/upload")]
fn upload(ctx: Context) -> Response {
let Some(multipart) = ctx.multipart() else {
return Response::bad_request();
};
for part in multipart {
let p = part.unwrap();
println!("Field: {}, File: {:?}", p.name.unwrap_or("?"), p.filename);
// p.body: &[u8] — zero-copy slice into the connection read buffer
}
Response::text("Upload Done")
}
Panic Recovery
Faulty routes are isolated via catch_unwind. A panic returns a
500 Server Error but keeps the worker alive and ready for the next request.
Custom HTTP Headers on Responses
// Builder pattern — chainable, returns Response
Response::json(&data)
.with_header("X-Request-Id", "abc-123")
.with_header("Cache-Control", "max-age=3600")
.with_header("Content-Disposition", "attachment; filename=\"data.json\"")
Pre-baked Raw Responses
For maximum throughput on a fixed response (e.g. health checks or benchmarks), pre-bake the full
HTTP response as a &'static [u8]. The worker writes it verbatim — no header
serialization, one memcpy.
static PONG: &[u8] = b"HTTP/1.1 200 OK\r\n\
Server: chopin\r\n\
Content-Type: text/plain\r\n\
Content-Length: 4\r\n\
Connection: keep-alive\r\n\
\r\n\
pong";
#[get("/ping")]
fn ping(_ctx: Context) -> Response { Response::raw(PONG) }
RFC 7233 Range Requests
Use Response::file_range(path, range_header) to honour HTTP range requests
(e.g. video seeking, resumable downloads):
#[get("/video/:name")]
fn serve_video(ctx: Context) -> Response {
let name = ctx.param("name").unwrap_or("");
let range = ctx.header("range"); // e.g. Some("bytes=0-1048575")
// Returns 206 Partial Content + Content-Range on valid range
// Returns 416 Range Not Satisfiable on invalid range
Response::file_range(&format!("media/{}", name), range)
}
WebSocket Upgrade
Chopin includes RFC 6455 support: upgrade handshake + a full frame codec.
use chopin_core::websocket;
#[get("/ws")]
fn ws_handler(ctx: Context) -> Response {
match websocket::ws_upgrade(&ctx) {
Some(resp) => resp, // 101 Switching Protocols
None => Response::bad_request(),
}
}
7. Performance & Scaling
Core Affinity
Workers are pinned to physical cores via core_affinity to maximize L1/L2
cache hits and eliminate context switching overhead.
writev Zero-Copy Flush
Headers and body are delivered in one writev syscall. Static
(&'static [u8]) and byte bodies bypass the write buffer — no
memcpy on the hot path.
sendfile File Serving
Response::file() transfers file bytes entirely in kernel space (Linux
sendfile / macOS sendfile). The user-space process never
touches the file data.
Pre-Composed Middleware
At startup, Router::finalize() walks the route tree and composes all
middleware chains into one Arc<dyn Fn> per route. Zero
Arc::new per request — just one pointer call.
mimalloc Allocator
Per-thread free lists and low lock-contention allocation via mimalloc.
Significantly lower allocation latency than the system allocator under concurrency.
Kernel Socket Handoff
Uses TCP_DEFER_ACCEPT and TCP_FASTOPEN (Linux) and
SO_NOSIGPIPE (macOS) to minimize round trips. TCP_NODELAY
is inherited by all accepted sockets from the listener.
mimalloc Global Allocator Setup
Chopin applies this automatically. For your own crates that depend on chopin-core, mimalloc
is already active as the workspace global allocator.
// In your Cargo.toml:
// mimalloc = { version = "0.1", default-features = false }
// In main.rs (Chopin does this for you):
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
8. Testing & Quality
Architectural Linter
chopin check
Benchmarking
chopin bench
wrk -t10 -c200 -d10s http://localhost:8080/ping
9. Deployment & Observability
Dockerization
chopin deploy docker
Prometheus Metrics
Mount a Prometheus scrape endpoint and a k8s-compatible health probe:
Chopin::new()
.mount_all_routes()
.with_metrics("/metrics") // GET /metrics → Prometheus text format
.with_health("/health") // GET /health → JSON body for k8s / ALB
.serve("0.0.0.0:8080")
.unwrap();
Structured Logging
Enable JSON-structured logging to stderr (controlled by RUST_LOG):
# Cargo.toml
chopin-core = { version = "0.5.27", features = ["logging"] }
RUST_LOG=info cargo run
TLS / HTTPS
Enable TLS (rustls 1.2/1.3) with the tls feature flag:
# Cargo.toml
chopin-core = { version = "0.5.27", features = ["tls"] }
Chopin::new()
.mount_all_routes()
.serve_tls("0.0.0.0:443", "certs/cert.pem", "certs/key.pem")
.unwrap();