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 via inventory.
  • 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();