# rws — Rust Web Server
> A single-binary web server and reverse proxy — automatic HTTPS (ACME / Let's Encrypt),
> HTTP/3, load balancing, auth, rate limiting, configured in one `rws.config.toml` — and
> the same engine as a Rust library (routing, middleware, MCP server, async ORM).
Source: https://github.com/bohdaq/rust-web-server · Website: https://rws8.tech/ ·
API: https://docs.rs/rust-web-server · License: MIT · MSRV: 1.88 · Version: 18.3.0
HTTP/1.1 parsing, routing, middleware, WebSocket, SSE, CORS and JSON are implemented in
the crate — no hyper, Tower or Axum; HTTP/2 and HTTP/3 use `h2` and `quinn`/`h3`, TLS uses
`rustls`. Every handler implements one trait,
`Application::execute(&Request, &ConnectionInfo) -> Result<Response, String>`, and every
cross-cutting concern (auth, rate limits, caching, tracing, the reverse proxy) is a
`Middleware` added with `app.wrap(layer)`.
Use it as:
- **a static file server** — `brew install bohdaq/tap/rws && rws`, `cargo binstall rust-web-server`, or `docker run -p 7878:7878 bohdaq/rws`
- **a config-driven proxy** — `[[route]]` / `[[upstream]]` in `rws.config.toml`, no code
- **automatic HTTPS** — an `[acme]` section with `domains = [...]`
- **a library** — `cargo add rust-web-server`
This file is every page of the documentation site in one document, generated from
`docs/src/content/docs/` by `ci/gen_llms.py`. The index with one line per page is
`llms.txt`.
---
# Quick Start
<https://rws8.tech/getting-started/quick-start/>
Pick the path that matches your goal. Each ends with a working server you can hit with `curl`.
---
## Path 1 — Static file server (zero code)
Install the binary and point it at a directory. No config file, no code.
```bash
brew install bohdaq/tap/rws # or: cargo binstall rust-web-server
# create something to serve
mkdir www && echo "<h1>Hello</h1>" > www/index.html
cd www
rws
```
```
Server is up and running at: http://0.0.0.0:7878
```
```bash
curl http://localhost:7878/
# <h1>Hello</h1>
```
`rws` serves every file under the current directory, handles range requests, sets ETags, negotiates gzip, and returns `404` for missing files — all with no configuration. A directory with no `index.html` renders a directory listing page instead of `404` — try `mkdir www/uploads && touch www/uploads/report.pdf && curl http://localhost:7878/uploads/`.
:::note[HTTPS in one flag]
If you have a TLS certificate, pass `--tls-cert-file=cert.pem --tls-key-file=key.pem` and the server automatically upgrades to HTTP/2 + HTTP/3:
```bash
rws --tls-cert-file=cert.pem --tls-key-file=key.pem
```
:::
---
## Path 2 — First route (library)
Use `rust-web-server` as a Rust library crate and define your own handlers.
### 1. Add dependencies
```toml title="Cargo.toml"
[dependencies]
rust-web-server = "18"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
### 2. Write a handler
```rust title="src/main.rs"
use rust_web_server::prelude::*;
fn hello(_req: &Request, _params: &PathParams, _conn: &ConnectionInfo, _state: &()) -> Response {
Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "Hello, world!")
}
#[tokio::main]
async fn main() {
let app = routes! {
App::with_state(()),
GET "/hello" => hello,
};
let (listener, pool) = Server::setup().unwrap();
tokio::join!(
Server::run_tls(listener, pool, app.clone()),
Server::run_quic(app),
Server::run_redirect(),
);
}
```
### 3. Run and verify
```bash
cargo run
```
```bash
curl http://localhost:7878/hello
# Hello, world!
```
### Handler signature
Every route handler receives four arguments:
| Argument | Type | What it contains |
|---|---|---|
| `_req` | `&Request` | Method, URI, headers, raw body bytes |
| `_params` | `&PathParams` | Named path segments (e.g. `:id`) |
| `_conn` | `&ConnectionInfo` | Client/server IP, port, SNI hostname |
| `_state` | `&S` | Your shared application state |
### Shared state
Replace `()` with any `Send + Sync` type to share state across handlers:
```rust
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
struct AppState {
counter: AtomicU64,
}
fn count(_: &Request, _: &PathParams, _: &ConnectionInfo, state: &Arc) -> Response {
let n = state.counter.fetch_add(1, Ordering::Relaxed);
Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, &format!("count: {n}"))
}
#[tokio::main]
async fn main() {
let state = Arc::new(AppState { counter: AtomicU64::new(0) });
let app = routes! {
App::with_state(state),
GET "/count" => count,
};
let (listener, pool) = Server::setup().unwrap();
tokio::join!(
Server::run_tls(listener, pool, app.clone()),
Server::run_quic(app),
Server::run_redirect(),
);
}
```
---
## Path 3 — Config-driven proxy (no code)
Drop an `rws.config.toml` next to the `rws` binary and it becomes a reverse proxy and load balancer. No code required.
### Minimal config
```toml title="rws.config.toml"
port = 8080
[[upstream]]
name = "api"
backends = ["localhost:3000", "localhost:3001"]
[upstream.health_check]
path = "/healthz"
interval_secs = 10
healthy_threshold = 2
[[route]]
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
upstream = "api"
[[route]]
[route.match]
path = "/"
[route.action]
type = "respond"
status = 200
body = "Gateway ready"
```
```bash
rws
# Server is up and running at: http://0.0.0.0:8080
```
```bash
curl http://localhost:8080/
# Gateway ready
curl http://localhost:8080/api/users
# proxied to localhost:3000 or localhost:3001 (round-robin, health-checked)
```
### What the config does
- `[[upstream]]` declares a named backend pool with health checking. Dead backends are removed automatically; they re-enter rotation once they pass `healthy_threshold` consecutive checks.
- `[[route]]` rules are evaluated top-to-bottom, first match wins. A `path` ending in `*` is a prefix match (`/api/*` matches `/api/users`); without it the path must match exactly. Requests no route matches fall through to the static-file server.
- `type = "proxy"` forwards the request to the named upstream. `type = "respond"` returns a fixed response.
:::note[Add middleware per route]
Rate limiting, Bearer auth, and request rewriting can all be added per-route inside the config file — no code change needed. See the Proxy & Gateway documentation for the full schema.
:::
:::tip[HTTPS in front of the proxy]
Add an `[acme]` section (Path 4) and `rws` gets Let's Encrypt certificates for the proxy itself. To use your own certificates instead, set `tls_cert_file` / `tls_key_file`; per-domain certificates go in `[[virtual_host]]` blocks.
:::
---
## Path 4 — Automatic HTTPS (no code)
On a server whose DNS points at it, with ports 80 and 443 reachable:
```toml title="rws.config.toml"
port = 443
http_redirect_port = 80
[acme]
domains = ["example.com"]
email = "you@example.com"
```
```bash
sudo rws # or grant the binary CAP_NET_BIND_SERVICE to bind 80/443 without root
```
On first start `rws` requests a certificate from Let's Encrypt (stored as
`cert.pem` / `key.pem`), then serves HTTPS with HTTP/2 and HTTP/3 and
redirects HTTP to HTTPS. Renewal runs in the background. Try it against the
Let's Encrypt staging CA first with `staging = true` — see
[Automatic HTTPS](/features/acme/).
```bash
curl -I http://example.com/ # 301 → https://example.com/
curl -I https://example.com/ # 200, valid certificate
```
---
## Next steps
- **[Dev Workflow](/getting-started/dev-workflow/)** — auto-rebuild and restart on every code change with `cargo-watch`
- **Routing** — named path parameters (`:id`), wildcards, virtual-host routing
- **Middleware** — `RateLimitLayer`, `JwtLayer`, `CacheLayer`, `OtelLayer`, and more via `.wrap(layer)`
- **MCP Server** — expose tools and resources to AI agents over the MCP Streamable HTTP protocol
- **ORM** — `#[derive(Model)]`, `QueryBuilder`, migrations, `HasMany` / `HasOne` / `BelongsTo`
---
# Examples
<https://rws8.tech/getting-started/examples/>
The repository's [`examples/`](https://github.com/bohdaq/rust-web-server/tree/main/examples)
directory holds small, complete apps. Each one runs with `cargo run --example
<name>` from a clone and has a test that `cargo test` runs, so they keep
compiling and working as the crate changes.
```bash
git clone https://github.com/bohdaq/rust-web-server && cd rust-web-server
cargo run --example hello
curl localhost:7878/hello/world # Hello, world!
```
Without a certificate they serve plain HTTP/1.1 on port 7878; a
`rws.config.toml` in the working directory, environment variables and
command-line flags apply as they do to `rws` (see
[Configuration](/configuration/env-vars/)).
| Example | Shows | Run | Read more |
|---|---|---|---|
| [`hello`](https://github.com/bohdaq/rust-web-server/blob/main/examples/hello.rs) | Two routes and a path parameter | `cargo run --example hello` | [Routing](/building-apps/routing/) |
| [`rest_api`](https://github.com/bohdaq/rust-web-server/blob/main/examples/rest_api.rs) | JSON CRUD over shared state, typed errors (400, 404, 422) | `cargo run --example rest_api --features serde` | [JSON](/building-apps/json/), [Error handling](/building-apps/error-handling/) |
| [`static_site`](https://github.com/bohdaq/rust-web-server/blob/main/examples/static_site.rs) | The working directory as a site, behind request-id and rate-limit middleware | `cargo run --example static_site` | [Static files](/features/static-files/), [Middleware](/building-apps/middleware/) |
| [`reverse_proxy`](https://github.com/bohdaq/rust-web-server/blob/main/examples/reverse_proxy.rs) | `/api/*` forwarded to a backend, the rest served locally | `cargo run --example reverse_proxy` | [Reverse proxy](/proxy/reverse-proxy/) |
| [`sse_ticker`](https://github.com/bohdaq/rust-web-server/blob/main/examples/sse_ticker.rs) | Server-sent events written as they are produced | `cargo run --example sse_ticker` | [Server-sent events](/features/sse/) |
| [`upload`](https://github.com/bohdaq/rust-web-server/blob/main/examples/upload.rs) | Large uploads streamed to disk, not buffered | `cargo run --example upload` | [Forms and uploads](/building-apps/forms-uploads/) |
| [`mcp_inventory`](https://github.com/bohdaq/rust-web-server/blob/main/examples/mcp_inventory.rs) | One domain served as a REST API and as MCP tools for agents | `cargo run --example mcp_inventory --features "macros serde sso"` | [Agent tools](/mcp/agent-tools/) |
| [`mcp_gateway`](https://github.com/bohdaq/rust-web-server/blob/main/examples/mcp_gateway.rs) | One authenticated MCP endpoint in front of several MCP servers | `cargo run --example mcp_gateway` | [MCP gateway](/mcp/gateway/) |
| [`mcp_conformance`](https://github.com/bohdaq/rust-web-server/blob/main/examples/mcp_conformance.rs) | The server the official MCP conformance suite tests | `cargo run --example mcp_conformance` | [MCP overview](/mcp/overview/) |
The comment at the top of each file has the `curl` commands to try it.
## Testing an example
Each example builds its app in a function (`app()`) that `main` serves and
its test drives through [`TestClient`](/testing/test-client/) — no socket,
no port. Copy that split into your own app:
```bash
cargo test --example rest_api --features serde
```
## For AI coding agents
[`AGENTS.md`](https://github.com/bohdaq/rust-web-server/blob/main/AGENTS.md)
at the repository root tells a coding agent how to build an app on the crate:
which entry point to start from, the handler signature, the conventions that
aren't obvious from the types, and where in these docs each topic lives. The
whole documentation is also available as one file,
[`llms-full.txt`](https://github.com/bohdaq/rust-web-server/blob/main/llms-full.txt).
---
# Installation
<https://rws8.tech/getting-started/installation/>
## Requirements
**Minimum Supported Rust Version (MSRV): 1.88**
Install or update Rust via [rustup](https://rustup.rs/):
```bash
rustup update stable
rustc --version # should print 1.88.0 or newer
```
## As a library crate
Add `rust-web-server` to your `Cargo.toml`:
```bash
cargo add rust-web-server
```
Or pin the major version manually:
```toml
[dependencies]
rust-web-server = "18"
```
The default feature set (`http3`) pulls in HTTP/3, HTTP/2, TLS, and a tokio async runtime. If you need a lighter build, opt out and select a lower feature tier (see the feature flags table below).
### Prelude
A single glob import covers the types you need in almost every handler:
```rust
use rust_web_server::prelude::*;
// Re-exports: App, Server, ConnectionInfo, Request, Response,
// STATUS_CODE_REASON_PHRASE, Range, MimeType,
// PathParams, New, routes!
```
## As a standalone binary
Every release ships prebuilt binaries, so no Rust toolchain is needed:
| Method | Command | Platforms |
|---|---|---|
| Homebrew | `brew install bohdaq/tap/rws` | macOS (Apple Silicon, Intel), Linux (x86_64, arm64) |
| cargo-binstall | `cargo binstall rust-web-server` | everything below, without compiling |
| apt / dnf | `sudo apt install rws`, `sudo dnf install rws` after adding the repository — [systemd: install from a package](/deployment/systemd/#install-from-a-package); installs and starts the `rws` service (since 18.2.0) | Debian, Ubuntu, Fedora, RHEL and derivatives (x86_64, arm64) |
| Docker | `docker run -p 7878:7878 -v "$PWD:/srv" bohdaq/rws` (also `ghcr.io/bohdaq/rws`) | linux/amd64, linux/arm64 |
| Download | [GitHub Releases](https://github.com/bohdaq/rust-web-server/releases/latest) | `x86_64`/`aarch64-unknown-linux-musl`, `x86_64`/`aarch64-apple-darwin`, `x86_64-pc-windows-msvc` |
| From source | `cargo install rust-web-server` | anywhere Rust 1.88+ runs |
On Windows, `rws.exe` also runs as a Windows service — see [Windows](/deployment/windows/).
Linux binaries are statically linked against musl, so they run on any distribution regardless of glibc version. Each archive has a matching `.sha256` file.
```bash
rws --version
rws --help # every option, with its environment-variable equivalent
```
Run it in any directory that contains static files and it will serve them on `http://localhost:7878` with no configuration file needed.
### Verifying a release
From 18.0.0, releases can be checked end to end:
```bash
# The archive was built by this repository's release workflow
gh attestation verify rws-18.0.0-x86_64-unknown-linux-musl.tar.gz --repo bohdaq/rust-web-server
# The image too — attestation, or the keyless cosign signature
gh attestation verify oci://ghcr.io/bohdaq/rws:18.0.0 --repo bohdaq/rust-web-server
cosign verify ghcr.io/bohdaq/rws:18.0.0 \
--certificate-identity-regexp 'https://github.com/bohdaq/rust-web-server/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Known vulnerabilities in the dependencies compiled into a binary
# (built with cargo-auditable, so the list travels with it)
cargo audit bin ./rws
```
Each release also has a CycloneDX SBOM (`rws-<version>.cdx.json`) of the
binary's dependency tree. CI checks every change with `cargo audit` and
`cargo deny` (advisories; permissive licenses only; crates.io sources only —
`deny.toml`).
## Build from source
```bash
git clone https://github.com/bohdaq/rust-web-server.git
cd rust-web-server
cargo build --release
# binary is at: target/release/rws
```
To build a specific feature tier from source:
```bash
# HTTP/2 + TLS only (no QUIC)
cargo build --release --no-default-features --features http2
# HTTP/1.1 only — no TLS, no async runtime, smallest binary
cargo build --release --no-default-features --features http1
```
## Feature flags
| Feature | What it adds | Required deps |
|---|---|---|
| `http1` | Synchronous thread-pool server, no async runtime, no TLS | `ctrlc`, `libc` |
| `http2` | tokio runtime, TLS via rustls (aws-lc-rs), HTTP/2 via ALPN | `h2`, `rustls`, `tokio`, `tokio-rustls`, … |
| `http3` *(default)* | QUIC transport and HTTP/3 on top of `http2` | `quinn`, `h3`, `h3-quinn` |
| `http-client` | HTTPS support in the outbound `Client` | `rustls`, `webpki-roots` |
| `serde` | JSON serialization/deserialization via serde | `serde`, `serde_json` |
| `auth` | `BasicAuthLayer`, `JwtLayer` (HS256), `ForwardAuthLayer`, JWT helpers | `hmac`, `sha2` |
| `auth-asymmetric` | RS256 / ES256 in `JwtLayer` (in-crate RSA verification) | `auth`, `p256`, `rand_core` |
| `macros` | `#[derive(Model)]`, `#[derive(FromRequest)]`, `#[derive(Validate)]`, `#[derive(Config)]`, `#[get]`/`#[post]`/… route attributes | `rws-macros` |
| `acme` | Automatic TLS certificate provisioning (ACME/Let's Encrypt) | `http2`, `rcgen`, `aws-lc-rs`, … |
| `mimalloc` | The `rws` binary allocates with mimalloc v3 (about 5% less CPU per request than glibc's allocator; more memory under load); release binaries and images have it. The library never sets a global allocator | `mimalloc` |
| `tera` | Tera template engine integration | `tera`, `serde`, `serde_json` |
| `model-sqlite` | Async ORM backend for SQLite | `http2`, `sqlx` (sqlite) |
| `model-postgres` | Async ORM backend for PostgreSQL | `http2`, `sqlx` (postgres) |
| `model-mysql` | Async ORM backend for MySQL / MariaDB | `http2`, `sqlx` (mysql) |
| `crypto` | Argon2id password hashing, signed (HMAC) and encrypted (AES-GCM) cookies | `argon2`, `rand_core`, `hmac`, `sha2`, `aes-gcm` |
| `csrf` | CSRF token generation and validation | `rand_core` |
| `mailer` | SMTP client (STARTTLS/SMTPS also need `http-client` or `http2`) | — |
| `sso` | OAuth 2.0 / OIDC client (RS256/ES256 `id_token` verification, outbound HTTPS) | `p256`, `sha2`, `rand_core`, `serde`, `serde_json`, `http-client` |
| `sso-server` | `AuthServer`: rws as an OAuth 2.0 authorization server (HS256 tokens) | `sso`, `auth` |
| `sso-saml` | `SamlSp`: SAML 2.0 Service Provider | `sso` |
| `jobs` | In-memory `JobQueue` (`PersistentJobQueue` also needs a `model-*` feature) | — |
| `storage-local` | `LocalStorage` | — |
| `storage-s3` | `S3Storage` (AWS SigV4, no SDK) | `hmac`, `sha2`, `http-client` |
| `storage-azure` | `AzureBlobStorage` (Shared Key / Managed Identity, no SDK) | `hmac`, `sha2`, `http-client` |
| `openapi` | `GET /openapi.json` + `GET /docs` from registered routes | — |
| `webhook` | `verify_webhook_signature` (GitHub, Shopify, Stripe) | `hmac`, `sha2` |
| `rewrite-regex` | `RewriteLayer::request_uri_regex_rewrite` | `regex` |
| `secrets` | `secrets::resolve()` for `vault://`, `aws-sm://`, `azkv://` references | `hmac`, `sha2`, `http-client` |
:::note[Combining features]
Features compose freely. For example, to build HTTP/2 + TLS + JSON + SQLite:
```toml
rust-web-server = { version = "18", default-features = false, features = ["http2", "serde", "model-sqlite"] }
```
:::
## Approximate binary sizes
These measurements are for a release build (`cargo build --release`) of the `rws` binary with only the core transport feature active. Enabling additional features (`serde`, `tera`, ORM backends, etc.) increases the size.
| Feature tier | Approx. binary size |
|---|---|
| `http1` | ~3 MB |
| `http2` | ~8 MB |
| `http3` (default) | ~12 MB |
:::note[No OpenSSL]
TLS uses `rustls` with the `aws-lc-rs` crypto backend. There is no dependency on a system OpenSSL installation. Binaries are fully static with respect to TLS and are compatible with FIPS-validated environments.
:::
## What's built in, and what isn't
HTTP/1.1 parsing, CORS, MIME types, range requests, WebSocket, SSE, and routing are implemented inside this crate. The transitive dependency tree has no `hyper`, `tower`, or `actix-net`.
The HTTP/2 server is rws's own (`h2_server`: framing, HPACK, flow control — conformance-checked with h2spec and fuzzed); the `h2` crate remains a dependency as the HTTP/2 *client* of `H2ReverseProxy`/`GrpcProxy`. HTTP/3 and QUIC use `h3` and `quinn`, and TLS uses `rustls`. `tokio-util` appears transitively in the default build through `h2`.
---
# Auto-Rebuild on Change (cargo-watch)
<https://rws8.tech/getting-started/dev-workflow/>
`rws` doesn't need any special support for this — it's the standard Rust dev-loop pattern, using an external file-watcher to rebuild and restart your binary. [`cargo-watch`](https://crates.io/crates/cargo-watch) is the simplest way to get it.
## Setup
```bash
cargo install cargo-watch
```
Run this from your app's project root — the one with a `Cargo.toml` that depends on `rust-web-server`, not inside the `rws` source tree itself:
```bash
cargo watch -x run
```
On every file save, `cargo-watch` kills the currently running process, rebuilds, and restarts it. You never run `cargo run` by hand again — the server you're hitting with `curl` (or a browser) is always built from the latest saved code.
## Useful variants
Fail fast on a compile error before it tries to restart the (now-stale) server:
```bash
cargo watch -x check -x run
```
Scope which paths are watched — useful if your project has directories generating unrelated file-system noise (build scripts, generated files):
```bash
cargo watch -w src -w Cargo.toml -x run
```
If you're developing against a local checkout of `rws` itself via a `path = "../rust-web-server"` dependency (rather than the crates.io release), watch both trees so library changes trigger a rebuild too:
```bash
cargo watch -w src -w ../rust-web-server/src -x run
```
## What to expect
- **Rebuild time** dominates the loop — typically a few seconds for an incremental build, longer the first time or after a `Cargo.toml` change.
- **The process fully restarts** on each change — it isn't a hot code swap. Any in-memory state (counters, caches you built yourself, in-memory `SessionStore`) is reset; state backed by `DbPool`, `RedisSessionStore`, or `DbSessionStore` survives across restarts since it lives outside the process.
- **In-flight requests during a restart** get connection-refused for the brief window between the old process exiting and the new one binding the port again. Fine for iterative development; if this is disruptive enough to matter (e.g. a frontend dev server proxying to your API while you're actively testing), see the note below.
:::tip[Want zero-downtime restarts instead?]
`Server::run()` and `Server::run_tls()` both accept an already-constructed `std::net::TcpListener` rather than always binding their own — which means the [`systemfd`](https://crates.io/crates/systemfd) + [`listenfd`](https://crates.io/crates/listenfd) pattern works with `rws` as-is: `systemfd` holds the listening socket open across restarts and hands it to each new process via `listenfd`, so there's no "address already in use" and no gap where the port stops accepting connections. This is a bigger setup than plain `cargo-watch` and only worth it if the restart gap is actually causing friction.
:::
---
# Frontend Dev Server Proxy (React / Vite / webpack)
<https://rws8.tech/getting-started/frontend-dev-proxy/>
`rws` is a production HTTP server, not a bundler — it has no built-in dev-mode HMR/asset-compilation story. During local development you run your frontend's own dev server (Vite, webpack-dev-server, etc.) for fast hot-module-reload, and `rws` as your API backend on a separate port. This page covers the two ways to wire the two together, and which one to reach for.
## Recommended: point the dev server's own proxy at rws
This is the standard pattern for every JS frontend tooling ecosystem, and the simplest option here — the frontend dev server proxies API requests to `rws`; everything else (the app bundle, HMR's own WebSocket) stays entirely inside the dev server, which already handles it correctly. The browser only ever talks to one origin (the dev server's port), so **no CORS configuration is needed at all**.
### Vite
```js
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:7878',
changeOrigin: true,
},
},
},
};
```
```bash
# Terminal 1
rws # backend on :7878 (RWS_CONFIG_PORT, default 7878)
# Terminal 2
npm run dev # Vite dev server on :5173, proxies /api/* to rws
```
Your React code just calls `fetch('/api/users')` — Vite transparently forwards it to `http://localhost:7878/api/users` and the response looks like it came from the same origin.
### webpack-dev-server (Create React App / custom webpack)
```js
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:7878',
changeOrigin: true,
},
},
},
};
```
Create React App reads the same shape from a `"proxy"` key in `package.json` instead, if you haven't ejected:
```json
{
"proxy": "http://localhost:7878"
}
```
## Alternative: call rws directly, cross-origin
If you'd rather not proxy through the dev server — e.g. you're testing against a shared staging backend rather than a local `rws` instance — call it directly from the browser at its own origin instead. A fresh `rws` sends no CORS headers (since 18.0.0), so list the dev server's origin:
```bash
export RWS_CONFIG_CORS_ALLOW_ALL=false
export RWS_CONFIG_CORS_ALLOW_ORIGINS=http://localhost:5173
export RWS_CONFIG_CORS_ALLOW_CREDENTIALS=true # only if you send cookies
rws
```
See [CORS & Security](/features/cors-security/) for the full set of `RWS_CONFIG_CORS_*` variables.
This works with no bundler-side config, but every request now does a real cross-origin fetch — mutating requests will trigger a CORS preflight `OPTIONS` round-trip, and cookie-based auth needs `credentials: 'include'` in `fetch`.
## Alternative: rws as the single front door
You can flip the direction — run `rws` as the one port you point your browser at, and have it reverse-proxy through to the dev server instead. `ReverseProxy`'s `.path_prefix()` is shaped for the opposite split from what you'd want here, though, so it's worth understanding before reaching for it:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// path_prefix("/api") means /api/* is what gets FORWARDED to the given
// backend — everything else falls through to the wrapped App instead.
let app = App::new() // serves everything that ISN'T /api locally
.wrap(ReverseProxy::new(["http://backend-api:8080"]).path_prefix("/api"));
```
That's the right shape for *production* — `App` serves your built, static frontend and `/api` is proxied to a real backend service (see [Reverse Proxy](/proxy/reverse-proxy/)). For the *dev-time* case on this page — proxy everything **except** `/api` to the dev server, and answer `/api` locally — there's no built-in "exclude prefix" on `ReverseProxy` (unlike `RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`'s exclude-list for the static-file fallback), so it isn't a one-line `.wrap()`. You'd need a small custom `Application` that checks the path itself and dispatches to either your local API handler or `ReverseProxy` explicitly — meaningfully more code than the [recommended approach](#recommended-point-the-dev-servers-own-proxy-at-rws) above for the same result.
:::caution[HMR's WebSocket does not pass through `ReverseProxy`, either way]
`ReverseProxy` strips the `Upgrade` header on every forwarded request (it's a plain buffered request/response HTTP/1.1 proxy, not a WebSocket-aware one — see [Reverse Proxy](/proxy/reverse-proxy/)). Vite's HMR client opens a WebSocket back to the dev server for live updates; proxied through `ReverseProxy` that connection simply won't upgrade, and Vite falls back to full-page reloads instead of true HMR — regardless of which direction the proxying goes.
This is the real reason the [recommended approach](#recommended-point-the-dev-servers-own-proxy-at-rws) above has the dev server proxy *to* rws, not the other way around: the dev server keeps serving its own bundle and HMR socket directly, so none of its WebSocket handling is ever in rws's path.
:::
Given both the extra composition work and the HMR limitation, reach for this direction only if you specifically need rws's own middleware (auth, rate limiting, IP filtering) sitting in front of the dev bundle for a test — otherwise the recommended approach above gets you the same local dev loop with far less setup.
## Production has none of this
In production there's no separate dev server at all — build your frontend (`npm run build`), point `rws` at the output directory, and let `StaticResourceController` serve it directly, with `RWS_CONFIG_SPA_FALLBACK` handling client-side-router deep links. See [Static Files, Directory Listing & SPA Fallback](/features/static-files/).
---
# All Features
<https://rws8.tech/getting-started/features/>
This page is a single-page reference for everything the crate ships. Items that require an opt-in feature flag are annotated with the flag name in backticks.
---
## Server / Protocol
- **HTTP/1.1** — keep-alive, chunked transfer encoding, gzip compression, ETags, range requests, 30-second idle read timeout, 30 s to receive a whole request head (`408`)
- **HTTP/2** — ALPN negotiation, full multiplexing, forbidden-header stripping, `Alt-Svc` advertisement — `http2`
- **HTTP/3 / QUIC** — UDP listener via `quinn`, `h3` + `h3-quinn`, QUIC connection-level SNI — `http3` *(default)*
- **TLS** — `rustls` with `aws-lc-rs` crypto backend; no system OpenSSL dependency; FIPS-compatible — `http2`
- **mTLS** — client certificate verification via `WebPkiClientVerifier`; set `RWS_CONFIG_TLS_CLIENT_CA_FILE` — `http2`
- **Virtual-host routing** — per-domain TLS certificates via `SniCertResolver`; `.with_host("example.com")` on any `Router` — `http2`
- **HTTP → HTTPS redirect** — `Server::run_redirect()` listens on a second port and issues `301` responses — `http2`
- **ACME / automatic TLS** — automatic certificate provisioning compatible with Let's Encrypt — `acme`
- **Hot reload** — `SIGHUP` (or `POST /admin/config/reload`) reloads CORS rules, rate limits, log format, and TLS certs without restarting — `http2`
- **Graceful shutdown** — `SIGINT` / `SIGTERM` drain in-flight requests before exit; `SERVER_READY` flag cleared on shutdown
- **Gzip compression** — applied automatically based on `Accept-Encoding` to text bodies of at least 512 bytes (`compression::MIN_COMPRESS_SIZE`)
- **Static file serving** — a directory with no `index.html` renders a dark/light-adaptive directory listing page by default; files over 256 KiB streamed (`sendfile(2)` on plain TCP); ETag + `Last-Modified` with `304` revalidation, `Cache-Control: no-cache` by default (`RWS_CONFIG_STATIC_CACHE_CONTROL`)
- **SPA fallback** — `RWS_CONFIG_SPA_FALLBACK=index.html` serves a fallback file for unmatched routes instead of `404`, for React Router/Vue Router/etc. deep links; opt-in, scoped by an extension heuristic and `RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`
- **Thread pool** (http1 only) — hand-rolled `ThreadPool`; connection-per-thread; no tokio — `http1`
- **Tokio async runtime** — used for `http2` and `http3` builds; `http1` is fully synchronous
- **Request framing** — heads up to 64 KiB across any number of reads (`431` beyond), pipelining, chunked request bodies; `Content-Length` + `Transfer-Encoding` or conflicting lengths are rejected (`400`) so a proxy in front can't be desynced (request smuggling)
- **`Expect: 100-continue`** — `100 Continue` is sent before the body is read, after the body-size check
- **Max request body size** — `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` rejects oversized bodies with `413` before buffering, on HTTP/1.1, HTTP/2 and HTTP/3
- **Outbound HTTP client** — `Client` (sync) and `AsyncClient` (`http2`); HTTPS with `http-client` or `http2`; redirects, `.form()`, `.body_json()`
- **WebSocket** (RFC 6455) — handshake and frame codec built in
- **WASM guest component** — `rws-wasm-shim` runs `App`/`Router`/middleware inside a `wasi:http/proxy` component (Wasmtime, Spin, Fastly Compute); see [WASM](/deployment/wasm/)
---
## Routing & App Building
- **`App`** — zero-config entry point; wraps all built-in controllers (static files, CORS, health, metrics, MCP, …)
- **`App::with_state(S)`** — state-aware dynamic router; `Arc` shared across all handlers; builder methods `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`
- **`App::with_async_state(S)`** — same API with `async fn` handlers — `http2`
- **`routes!` macro** — ergonomic route registration: `GET "/path" => handler` — `macros`
- **`Router`** — standalone path-based router with named parameters (`:name`) and trailing wildcards (`*name`); `PathParams::get(name)`
- **`Controller` trait** — low-level `is_matching` / `process` interface; register in `App::execute` for maximum control
- **Typed extractors** — `Body` (raw bytes), `BodyText` (UTF-8), `Query` (parsed query string), `RequestHeaders` (case-insensitive header map)
- **Typed errors** — `AppError` enum (`BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `UnprocessableEntity`, `TooManyRequests`, `Internal`) with `IntoResponse` trait
- **`Middleware` trait** — `handle(request, connection, next) -> Result`; compose via `.wrap(layer)`
- **`WithMiddleware`** — wraps any `Application`; layers applied in push order (first-pushed is outermost)
- **JSON (de)serialization** — `serde` / `serde_json` integration (e.g. `http_client::Response::json::()`) — `serde`
- **Tera templates** — render `.html` templates with a `serde_json::Value` context via `tera::render` — `tera`
- **In-process test client** — `TestClient::new(app)` dispatches requests without opening a TCP socket; use in unit and integration tests
- **WebSocket** — RFC 6455 handshake and frame codec built in; no third-party WebSocket library
- **SSE (Server-Sent Events)** — `Sse` builder for streaming events; ideal for streaming AI model output
- **Background scheduler** — `Scheduler::new().every(duration, fn).cron("…", fn).start()`
- **Background job queue** — `JobQueue` (in-memory) or `PersistentJobQueue` (crash-safe, model-backed); retry with exponential backoff — `jobs`
- **File / object storage** — `Storage` trait; `LocalStorage` (disk), `S3Storage` (AWS S3, R2, MinIO via AWS SigV4, no SDK), or `AzureBlobStorage` (Azure Blob via Shared Key/Managed Identity, no SDK) — `storage-local` / `storage-s3` / `storage-azure`
- **OpenAPI / Swagger** — `.openapi(OpenApiConfig)` generates `GET /openapi.json` + `GET /docs` (Swagger UI) from registered routes — `openapi`
- **Email / SMTP** — `mailer` module, plain SMTP; STARTTLS/SMTPS also needs `http-client` or `http2` — `mailer`
- **Outbound HTTP client** — `http_client::Client` (always compiled for plain HTTP); HTTPS without the server TLS stack — `http-client`
- **Per-route timeouts** — `with_timeout`/`with_timeout_state`/`with_timeout_async` wrap a handler with its own deadline; `TimeoutLayer` for a whole app; config-driven proxy's `timeout_ms`
- **Request ID middleware** — `RequestIdLayer` injects/echoes `X-Request-Id` on every request and response; `RequestId` extractor to read it
---
## Proxy & Gateway
- **`ReverseProxy`** — HTTP/1.1 reverse proxy middleware; round-robin backend selection
- **`H2ReverseProxy`** — HTTP/2 upstream proxy; bridges sync middleware into async via an isolated-runtime helper (works under any tokio runtime flavor) — `http2`
- **`GrpcProxy`** — wraps `H2ReverseProxy`; filters on `Content-Type: application/grpc*` — `http2`
- **`TcpProxy`** — standalone L4 TCP proxy; bidirectional `std::io::copy` relay; round-robin backends
- **`UdpProxy`** — standalone UDP datagram proxy; per-datagram ephemeral socket; round-robin backends
- **`WsProxy`** — standalone WebSocket proxy; relays raw bytes after upgrade handshake
- **Config-driven proxy mode** — `rws.config.toml` with `[[upstream]]` + `[[route]]` sections; no code required
- **Health checking** — per-upstream background health checker; dead backends removed automatically; configurable thresholds and intervals
- **Load balancing** — `round_robin`, `random`, `ip_hash`, or `least_connections` across live backends
- **`RewriteLayer`** — rewrite request headers, URI, and response headers / body / status code per route; regex URI rewrites via `request_uri_regex_rewrite` — `rewrite-regex`
- **Per-route rate limiting** — `PerRouteRateLimit` in config-driven mode; `RateLimitLayer` in code
- **Per-route Bearer auth** — `BearerAuthMiddleware` in config-driven mode; `JwtLayer` in code
- **Route matching** — host, path prefix, exact path, HTTP method, `Content-Type` prefix; first-match wins
- **Service discovery** — `BackendPool` with `Static`/`EnvPrefix`/`File`/`Dns`/`DnsSrv` (weighted)/`Consul`/`Docker` (label value as address) sources on a poll loop, plus `EtcdWatch` on a dedicated live watch stream
- **Circuit breaker + retries** — `CircuitBreaker` (Closed → Open → HalfOpen, capped probes), `RedisCircuitBreaker` (state shared across instances), `RetryLayer` on 502/503/504; `ReverseProxy::with_circuit_breaker`; `rws_circuit_breaker_state` metric
- **Canary / traffic splitting** — `CanaryLayer` with smooth weighted round-robin, live weight updates, `BackendPool` groups
- **Kubernetes Ingress** — `KubernetesIngressWatcher` watches the API (Exact/Prefix `pathType`, `ingressClassName`), `.from_service_account()` in-cluster
- **Private-CA upstreams** — `[[upstream]] tls_ca_file` / `ReverseProxy::tls_ca_file`
- **Non-blocking proxying** — config routes without middleware forward on the event loop, holding no thread
---
## Security
- **`RateLimitLayer`** — sliding-window rate limiter keyed by client IP; global singleton via `rate_limit::global()`; returns `429` when budget exceeded
- **`RedisRateLimiter`** — fixed-window limiter in Redis for a budget shared across instances
- **`BasicAuthLayer`** — HTTP Basic authentication middleware — `auth`
- **`JwtLayer`** — JWT Bearer token validation middleware (HS256) — `auth`; RS256/ES256 against a static public key via `::rs256`/`::es256` — `auth-asymmetric`
- **`ForwardAuthLayer`** — delegates the allow/deny decision to an external HTTP service (Traefik/nginx `auth_request` style) — `auth`
- **`IpFilter`** — allowlist / denylist middleware keyed by client IP
- **`crypto`** — Argon2id password hashing and verification; `signed_cookie`/`verify_signed_cookie` (HMAC-SHA256) and `encrypted_cookie`/`decrypt_cookie` (AES-256-GCM) — `crypto`
- **CSRF tokens** — secure random token generation and validation — `csrf`
- **SSO / OAuth 2.0 / OIDC** — `OidcAuth` login flow, RS256/ES256 `id_token` verification via JWKS, outbound HTTPS token exchange — `sso`
- **OAuth 2.0 Authorization Server** — `AuthServer`: rws issues its own HS256 tokens (`/oauth/token`, `/oauth/authorize`) — `sso-server`
- **SAML 2.0 Service Provider** — `SamlSp`: metadata, login, ACS, logout — `sso-saml`
- **Webhook verification** — `verify_webhook_signature` for GitHub, Shopify, Stripe — `webhook`
- **Secrets manager references** — `secrets::resolve()` for `vault://`, `aws-sm://`, `azkv://` — `secrets`
- **CORS** — built-in CORS controller; configurable origins, methods, and headers; hot-reloadable
- **mTLS** — mutual TLS client certificate verification — `http2`
- **No OpenSSL** — TLS via `rustls` + `aws-lc-rs`; fully static binaries
---
## Observability & Ops
- **`/healthz`** — liveness probe; always `200 OK` while the server is up
- **`/readyz`** — readiness probe; returns `503` after graceful shutdown begins (`SERVER_READY` flag)
- **`/metrics`** — Prometheus text-format scrape endpoint
- **`MetricsLayer`** — per-route `rws_route_requests_total{method,path,status}` counters and `rws_route_duration_seconds{method,path}` histograms
- **Global metrics** — `record_request()`, `record_error()`, `connection_open()` / `connection_close()` counters
- **`OtelLayer`** — OpenTelemetry trace propagation middleware
- **`CacheLayer`** — response caching middleware
- **Structured logging** — `RWS_CONFIG_LOG_FORMAT=json` (default), `combined`, or `off`; hot-reloadable
- **Config hot reload** — `SIGHUP` or `POST /admin/config/reload` reloads CORS, rate limits, log format, TLS certs; no restart required
- **Kubernetes-ready** — `/healthz`, `/readyz`, `/metrics` all built in; graceful `SIGTERM` drain; static binary with no system dependencies
- **`rws --check`** — validates `rws.config.toml` (unknown keys, bad values, TLS files, undeclared upstreams, unset auth secrets) and exits 1 on errors, like `nginx -t`; `--format=json`
- **Helm chart** — `helm/rws/`
- **Background scheduler** — fixed-rate, fixed-delay and 6-field cron tasks
- **Job queue** — `JobQueue` (in-memory) and `PersistentJobQueue` (model-backed); retries with exponential backoff — `jobs`
- **Email** — `Mailer` over SMTP, STARTTLS or SMTPS; text + HTML — `mailer`
---
## AI & MCP
- **`McpServer`** — implements `Application`; serves the MCP Streamable HTTP protocol (`POST /mcp`, JSON-RPC 2.0)
- **Tool registration** — `.tool(name, description, schema, handler)` — any function returning a string
- **Resource registration** — `.resource(uri_template, name, description, handler)`
- **Prompt registration** — `.prompt(name, description, handler)`
- **Bearer auth gate** — `.require_bearer(token)` gates all MCP requests behind a static token
- **Fallthrough** — `.wrap(app)` passes non-MCP requests to another `Application`
- **20 built-in rws tools** in the binary — config, metrics, routes, cache, rate limits, CORS, static files, reload, maintenance mode, IP blocklist, feature flags, recent requests/errors (see [MCP](/mcp/overview/))
- **SSE streaming** — `Sse` builder for streaming AI tokens to browser clients
- **Protocol coverage** — session context (`.tool_with_context`), tool annotations, image/embedded content, JSON-RPC batches, list pagination, `GET /mcp` SSE push, `logging/setLevel`, dynamic registration with `list_changed`, progress, `completion/complete`, cancellation, `resources/subscribe`, server-side sampling, `roots/list`, async tools (`http2`), OAuth (`.require_oauth`, `sso`)
---
## Database / ORM
All ORM features require at least one of `model-sqlite`, `model-postgres`, or `model-mysql` (async, backed by `sqlx`; several can be compiled in, pick one with `RWS_DB_BACKEND`).
- **`#[derive(Model)]`** — generates `impl Model`, `Struct::repository()`, `Struct::query()` — `macros`
- **Table and column mapping** — `#[table(name = "…")]`, `#[column(name = "…")]`, `#[primary_key]`, `#[primary_key(auto_increment)]`, `#[ignore]`
- **`DbPool`** — async `sqlx` pool: `execute`, `query_rows`, `query::`, `query_raw`, `begin` (→ `DbTransaction` with `commit`/`rollback`), `transaction(closure)`, `migrate`
- **`Repository`** — `save` (INSERT or UPDATE by primary key), `save_all`, `find_by_id`, `find_all`, `delete_by_id`, `delete_all_by_id`, `count`, `exists_by_id`
- **`QueryBuilder`** — fluent `where_eq`, `filter`, `order_by`, `limit`, `offset`, `paginate`, `fetch_all`, `fetch_one`, `count`, `delete`, `update`; placeholder tokens auto-converted to `?` (SQLite/MySQL) or `$N` (PostgreSQL)
- **Migrations** — reads `*.sql` files in lexicographic order; `_schema_migrations` table tracks applied versions; each migration wrapped in a transaction
- **Relations** — `HasMany`, `HasOne`, `BelongsTo` with explicit `.load(&pool)`; no lazy loading, no hidden N+1 queries
- **SQLite** — `sqlx` SQLite driver — `model-sqlite`
- **PostgreSQL** — `sqlx` Postgres driver — `model-postgres`
- **MySQL / MariaDB** — `sqlx` MySQL driver — `model-mysql`
---
## Sessions & Cookies
- **`CookieJar` / `SetCookie`** — parse request cookies, write every RFC 6265 attribute
- **Sessions** — `SessionStore` (in-memory TTL), `DbSessionStore` (model-backed), `RedisSessionStore`
---
## File / Object Storage
- **`Storage` trait** — `put` / `get` / `delete` / `url`; swap backends without touching handlers
- **`LocalStorage`** — local disk — `storage-local`
- **`S3Storage`** — AWS S3, Cloudflare R2, MinIO via SigV4; static keys or EKS IRSA / ECS task role / EC2 IMDSv2; no AWS SDK — `storage-s3`
- **`AzureBlobStorage`** — Shared Key or Managed Identity; no Azure SDK — `storage-azure`
---
## Secrets
- **`secrets::resolve`** — `vault://path#field`, `aws-sm://name[#field]`, `azkv://vault/secret` resolved over HTTPS — `secrets`
- **Any `RWS_CONFIG_*` can be a reference** — `resolve_env_vars()` runs in `Server::setup()`; a broken reference fails startup instead of running with the literal string
---
## Dependency Injection
- **`Container`** — `TypeId`-keyed service store for concrete types and `dyn Trait` objects
- **`register::(value)`** — wraps in `Arc`, keyed by `TypeId::of::()`
- **`provide::(Arc)`** — stores trait objects directly
- **Named services** — `get_named::(name)` / `register_named::(name, value)` for multiple instances of the same type
- **`App::with_state(container)`** — pass the container directly as state; it's `Send + Sync + 'static` like any other state type, no wrapping needed
- **`into_arc()`** — for sharing one container across multiple hand-built `Application`s outside of `with_state`
- No external dependencies
---
# Configuration Overview
<https://rws8.tech/configuration/overview/>
`rust-web-server` uses a four-layer configuration system. Each layer can override
the one below it. All resolved values are stored as process environment variables
under the `RWS_CONFIG_*` namespace and read at request time — there is no config
struct passed through the call stack.
## Priority order (lowest to highest)
| Priority | Source | Notes |
|----------|--------|-------|
| 1 | Built-in defaults | Hardcoded in `src/entry_point/mod.rs` |
| 2 | System environment variables | Set before the process starts |
| 3 | `rws.config.toml` | Optional file in the working directory |
| 4 | CLI arguments | Flags passed on the command line |
A value set at a higher priority layer always wins. For example, `--port=9000` on
the command line overrides `port = 8080` in `rws.config.toml`, which in turn
overrides `RWS_CONFIG_PORT=8888` in the environment.
## How startup loading works
`Server::setup()` first calls `set_default_values()`, which gives the core
settings (IP, port, thread count, request allocation size, CORS, TLS paths, …)
their compiled-in default when no environment variable is set. Then
`bootstrap()` in `src/entry_point/mod.rs` runs three steps in order:
```rust
read_system_environment_variables(); // step 1: read existing env vars
override_environment_variables_from_config(None); // step 2: apply rws.config.toml
override_environment_variables_from_command_line_args(); // step 3: apply CLI flags
```
Settings without a default there fall back to the default in the code that
reads them. Per-request settings (CORS, CSP, client hints, static
`Cache-Control`, log format, …) are read through `ServerConfig::current()`, a
snapshot rebuilt only after something writes `RWS_CONFIG_*`.
The config file path can be overridden at the OS level:
```bash
RWS_CONFIG_FILE=/etc/rws/production.toml rws
```
## Hot reload
Some settings can be changed while the server is running without a restart.
Trigger a reload with either:
```bash
# Unix signal
kill -HUP $(pidof rws)
# HTTP endpoint (no body required)
curl -X POST http://localhost:7878/admin/config/reload
```
### What reloads
| Setting | Environment variable |
|---------|---------------------|
| CORS — all fields | `RWS_CONFIG_CORS_*` |
| Rate-limit thresholds | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`, `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
| Log format | `RWS_CONFIG_LOG_FORMAT` |
| Request allocation size | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
| Security headers, CSP, client hints, nanosecond timestamps, static `Cache-Control` | `RWS_CONFIG_SECURITY_HEADERS`, `RWS_CONFIG_CSP`, `RWS_CONFIG_CLIENT_HINTS`, `RWS_CONFIG_NANOS_HEADERS`, `RWS_CONFIG_STATIC_CACHE_CONTROL` |
| Proxy routes and upstreams | `[[route]]`, `[[upstream]]`, `[middleware]` (kept as before if the new file has errors) |
| Tera templates | re-globbed from `RWS_CONFIG_TEMPLATE_DIR` (`tera` feature) |
On TLS builds, `SIGHUP` also rebuilds the `TlsAcceptor` from the updated
certificate files for all virtual hosts — no restart needed to rotate certs.
### What requires a restart
| Setting | Reason |
|---------|--------|
| IP / Port | The bound socket cannot be moved |
| Thread count | The thread pool is fixed at startup |
| Enabling the global rate limit | Decided once at startup (changing its numbers reloads) |
| `[[tcp_proxy]]`, `[[udp_proxy]]`, `[[ws_proxy]]` | Listeners start once |
| TLS cert / key paths (plain HTTP build) | The acceptor is built once |
:::note[Rate-limit changes are instant]
When a reload fires, the live global `RateLimiter` is updated in place via
`set_limits()`. Existing per-IP counters are preserved — only the limit values
change. The new limits apply to the very next request.
:::
## Reading config in handler code
`config_reload::current()` returns a typed `ConfigSnapshot` containing all
hot-reloadable values. It takes a brief read lock and clones a handful of
strings — safe to call on every request.
```rust
use rust_web_server::config_reload;
let cfg = config_reload::current();
if cfg.cors_allow_all {
// allow any origin
}
println!("log format: {}", cfg.log_format);
```
The `ConfigSnapshot` fields map directly to environment variables:
| Field | Environment variable |
|-------|---------------------|
| `cors_allow_all` | `RWS_CONFIG_CORS_ALLOW_ALL` |
| `cors_allow_origins` | `RWS_CONFIG_CORS_ALLOW_ORIGINS` |
| `cors_allow_credentials` | `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` |
| `cors_allow_methods` | `RWS_CONFIG_CORS_ALLOW_METHODS` |
| `cors_allow_headers` | `RWS_CONFIG_CORS_ALLOW_HEADERS` |
| `cors_expose_headers` | `RWS_CONFIG_CORS_EXPOSE_HEADERS` |
| `cors_max_age` | `RWS_CONFIG_CORS_MAX_AGE` |
| `rate_limit_max_requests` | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` |
| `rate_limit_window_secs` | `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
| `log_format` | `RWS_CONFIG_LOG_FORMAT` |
| `request_allocation_size` | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
For settings that are not in `ConfigSnapshot` (IP, port, thread count, TLS
paths) read them directly:
```rust
let port = std::env::var("RWS_CONFIG_PORT").unwrap_or_else(|_| "7878".to_string());
```
## Memory
`rws` reads each request in chunks of `request_allocation_size_in_bytes`
(default 10000 bytes); larger bodies span several reads, and
`max_body_size_in_bytes` caps them (`0` = unlimited). Static files over
256 KiB are streamed from disk rather than buffered, and so are proxied
upstream bodies over 1 MB.
---
# Environment Variables
<https://rws8.tech/configuration/env-vars/>
All configuration in `rust-web-server` is ultimately stored as process
environment variables. The variables below can be set before the process starts
(e.g. in a shell, a `docker run -e` flag, a Kubernetes `env:` block, or a
`systemd` unit file). Settings in `rws.config.toml` and CLI flags map to the
same variables and will override anything set in the environment.
See the [Configuration Overview](/configuration/overview) for the full priority
order.
## Server
| Variable | Default | Type | Description |
|----------|---------|------|-------------|
| `RWS_CONFIG_IP` | `0.0.0.0` | string | IP address the server binds to. `0.0.0.0` makes the server reachable from any interface, including inside containers and Kubernetes pods. Override to `127.0.0.1` for local-only development. |
| `RWS_CONFIG_PORT` | `7878` | integer | TCP port the server listens on. |
| `RWS_CONFIG_THREAD_COUNT` | `200` | integer | Size of the worker thread pool (synchronous HTTP/1.1 build only). |
| `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` | `10000` | integer | Size in bytes of each individual socket read. Bodies larger than this are accumulated across multiple reads automatically (as long as `Content-Length` is present) — this tunes read-syscall chunk size, not the maximum accepted body size. |
| `RWS_CONFIG_SERVE_DOTFILES` | `false` | boolean | Serve files and directories whose name starts with `.` (`.git/`, `.env`). Off by default since 17.113.1; `.well-known/` is always served. Read at startup. |
| `RWS_CONFIG_OPEN_FILE_CACHE_SECS` | `1` | integer | How long an open static file is reused before its path is checked again (like nginx's `open_file_cache`). `0` turns the cache off. Edits in place are always served correctly; a replaced or deleted file is noticed within this many seconds. At most 128 files are held open. Read at startup; Unix only. |
| `RWS_CONFIG_OPEN_FILE_CACHE_STRICT` | `true` | boolean | `true`: every static request checks the cached file's size and modification time (one `fstat`), so an edit in place is served at once. `false`: that check is reused for `RWS_CONFIG_OPEN_FILE_CACHE_SECS`, like nginx's `open_file_cache_valid` — about 0.7 µs less per request, but a file edited in place can be served stale (old size or bytes) for up to that long. Read at startup. |
| `RWS_CONFIG_RUNTIME` | `per-core` (`rws` binary; `shared` for library apps) | string | Async builds, Unix only. The `rws` binary runs `per-core` since 18.0.0 (`shared` before); an application built on the library stays `shared` unless it sets this. `shared`: one multi-threaded tokio runtime (`TOKIO_WORKER_THREADS` workers) takes every connection. `per-core`: one single-threaded runtime per worker, each accepting on its own `SO_REUSEPORT` socket on the same port, like nginx's worker processes — a connection stays on one thread, with no work stealing or cross-thread wake-ups. On fast CPUs that's more throughput (on the benchmark's fastest runners +12–16% requests per second, and 4% less CPU per proxied request); on slower ones it costs about 12% more CPU per small request for a few percent more throughput. HTTP/3, the redirect listener and ACME stay on the main runtime; SIGHUP and shutdown work as usual. Read at startup. |
| `RWS_CONFIG_STATIC_CACHE_CONTROL` | `no-cache` | string | `Cache-Control` sent with static files (since 18.0.0; other responses keep `no-store, …`). The default lets browsers keep a file and revalidate it with `If-None-Match` / `If-Modified-Since`, which costs a `304` instead of the whole file. Set e.g. `public, max-age=3600` for assets with versioned names; an empty value sends the `no-store` default for static files too. |
| `RWS_CONFIG_CLIENT_HINTS` | `false` | boolean | Send `Accept-CH`, `Critical-CH` and the client hints in `Vary` on every response (always on before 18.0.0). Off by default: they add ~700 bytes to every response, and `Critical-CH` makes Chromium retry a request that arrived without the hints. |
| `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` | `0` | integer | Maximum accepted request body size in bytes. `0` means unlimited. When set, a request whose `Content-Length` exceeds this is rejected with `413 Payload Too Large` before any body bytes are read, and the connection is closed. Applies to HTTP/1.1, HTTP/2, and HTTP/3. |
| `RWS_CONFIG_CSP` | unset | string | `Content-Security-Policy` header value, sent on every response. Unset: `default-src 'self'` with `RWS_CONFIG_SECURITY_HEADERS=true`, otherwise no header (18.0.0; was always `default-src 'self'`). An empty value suppresses the header. |
| `RWS_CONFIG_SECURITY_HEADERS` | `false` | boolean | Send `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: geolocation=(), microphone=(), camera=()` and the default CSP on every response (always sent before 18.0.0). `X-Content-Type-Options: nosniff` is always sent. |
| `RWS_CONFIG_NANOS_HEADERS` | `false` | boolean | Send `Date-Unix-Epoch-Nanos` on every response and `Last-Modified-Unix-Epoch-Nanos` on static files (always sent before 18.0.0). |
| `RWS_CONFIG_SPA_FALLBACK` | `` (empty) | path | File (e.g. `index.html`) served for a `GET`/`HEAD` that matches no file or directory, instead of `404` — for client-side routers. Paths whose last segment contains a `.` still get `404`. Empty disables it. |
| `RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES` | `` (empty) | string | Comma-separated path prefixes (e.g. `/api,/healthz`) that never get the SPA fallback. |
| `RWS_CONFIG_FILE` | `rws.config.toml` | path | Config file to load (settings and proxy routes) instead of `./rws.config.toml`. |
:::note[Thread count and the async build]
`RWS_CONFIG_THREAD_COUNT` only applies to the `http1`-only build, which uses a
hand-rolled `ThreadPool`. The `http2` / `http3` builds run on tokio and manage
concurrency differently — this variable is read but ignored at runtime.
:::
## TLS
| Variable | Default | Type | Description |
|----------|---------|------|-------------|
| `RWS_CONFIG_TLS_CERT_FILE` | `` (empty) | path | Path to the PEM-encoded certificate chain file. When non-empty, enables HTTPS, HTTP/2, and HTTP/3. |
| `RWS_CONFIG_TLS_KEY_FILE` | `` (empty) | path | Path to the PEM-encoded private key file. Must match `RWS_CONFIG_TLS_CERT_FILE`. |
| `RWS_CONFIG_TLS_CLIENT_CA_FILE` | `` (empty) | path | Path to a PEM-encoded CA certificate used to verify client certificates (mTLS). When set, the TLS handshake requires a valid client certificate signed by this CA. Connections without a valid cert are rejected before any HTTP processing. |
| `RWS_CONFIG_HTTP_REDIRECT_PORT` | `` (empty) | integer | When non-empty, a plain-HTTP listener is started on this port that issues `301 Moved Permanently` redirects to HTTPS. Set to `80` when running on standard ports. Requires TLS to be configured. |
### Virtual hosts
Per-domain TLS is configured through indexed variables. The server reads entries
sequentially, stopping at the first missing index.
| Variable | Description |
|----------|-------------|
| `RWS_CONFIG_VIRTUAL_HOST_0_DOMAIN` | Domain name for virtual host 0 (e.g. `example.com`) |
| `RWS_CONFIG_VIRTUAL_HOST_0_CERT_FILE` | Certificate file for virtual host 0 |
| `RWS_CONFIG_VIRTUAL_HOST_0_KEY_FILE` | Private key file for virtual host 0 |
| `RWS_CONFIG_VIRTUAL_HOST_1_DOMAIN` | Domain name for virtual host 1 |
| `RWS_CONFIG_VIRTUAL_HOST_1_CERT_FILE` | Certificate file for virtual host 1 |
| `RWS_CONFIG_VIRTUAL_HOST_1_KEY_FILE` | Private key file for virtual host 1 |
Continue the pattern (`_2_`, `_3_`, …) for as many domains as needed. The
`SniCertResolver` selects the correct certificate at TLS handshake time.
### ACME (Automatic Certificate Management)
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_CONFIG_ACME_DOMAINS` | `` (empty) | Comma-separated list of domain names. Setting this activates ACME at startup. Example: `example.com,www.example.com`. |
| `RWS_CONFIG_ACME_EMAIL` | `` (empty) | Contact email sent to the CA. Recommended but not required by all CAs. |
| `RWS_CONFIG_ACME_STAGING` | `false` | Set to `true` to use the Let's Encrypt staging environment for testing. |
| `RWS_CONFIG_ACME_DIRECTORY` | `` (empty) | Custom ACME directory URL. Defaults to Let's Encrypt production when empty. |
| `RWS_CONFIG_ACME_CERT_PATH` | `` (empty) | Where to write the provisioned certificate chain (PEM). Defaults to `RWS_CONFIG_TLS_CERT_FILE`, else `.rws/acme/cert.pem` (since 17.113.1; was `cert.pem` in the web root). When TLS paths are unset, ACME also points the TLS listener at this file. |
| `RWS_CONFIG_ACME_KEY_PATH` | `` (empty) | Where to write the certificate's private key (PEM). Defaults to `RWS_CONFIG_TLS_KEY_FILE`, else `.rws/acme/key.pem`; written `0600`. |
| `RWS_CONFIG_ACME_CHALLENGE_PORT` | `80` | Port the CA connects to for HTTP-01. A temporary listener answers at startup; once the redirect listener (`RWS_CONFIG_HTTP_REDIRECT_PORT`) runs on this port it answers renewal challenges. Must be reachable from the internet on port 80. |
| `RWS_CONFIG_ACME_RENEW_BEFORE_DAYS` | `` (empty) | Renew this many days before expiry. Empty (the default since 18.0.0; it was `30`): at two-thirds of the certificate's lifetime, or earlier when the CA's renewal information (RFC 9773) says so. |
| `RWS_CONFIG_ACME_PROFILE` | `` (empty) | Certificate profile to order, e.g. `shortlived` (6-day certificates) or `tlsserver` at Let's Encrypt. Empty: the CA's default. A profile the CA doesn't offer stops provisioning with the list it offers. |
| `RWS_CONFIG_ACME_ACCOUNT_KEY_PATH` | `.rws/acme/account.key` | Path to persist the ACME account key between restarts (written `0600`). |
| `RWS_CONFIG_ACME_CA_FILE` | `` (empty) | PEM file of extra root CAs to trust for the ACME directory, on top of the public roots — for private ACME CAs (step-ca, Pebble). |
| `RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS` | `43200` | Longest time between renewal checks (sooner when renewal is due earlier or the CA's renewal information asks). After a failed renewal the next attempt comes within an hour. |
## CORS
Off by default (since 18.0.0). When `RWS_CONFIG_CORS_ALLOW_ALL` is `true` the server sets permissive CORS
headers on every response — any origin, with credentials — and the more specific `CORS_ALLOW_*` variables are
ignored.
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_CONFIG_CORS_ALLOW_ALL` | `false` | When `true`, allows all CORS requests from any origin, with credentials. Was `true` before 18.0.0. |
| `RWS_CONFIG_CORS_ALLOW_ORIGINS` | `` (empty) | Comma-separated list of allowed origins. Example: `https://app.example.com,https://admin.example.com`. Ignored when `CORS_ALLOW_ALL` is `true`. |
| `RWS_CONFIG_CORS_ALLOW_METHODS` | `` (empty) | Comma-separated list of allowed HTTP methods. Example: `GET,POST,PUT`. |
| `RWS_CONFIG_CORS_ALLOW_HEADERS` | `` (empty) | Comma-separated list of allowed request headers (lowercase). Example: `content-type,x-api-key`. |
| `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` | `` (empty) | Set to `true` to allow cookies and credentials to be sent with cross-origin requests. |
| `RWS_CONFIG_CORS_EXPOSE_HEADERS` | `` (empty) | Comma-separated list of response headers the browser may expose to JavaScript (lowercase). |
| `RWS_CONFIG_CORS_MAX_AGE` | `86400` | How long (in seconds) the browser may cache a preflight response. Default is 24 hours. |
All CORS variables are hot-reloadable. Send `SIGHUP` or `POST /admin/config/reload`
to apply changes without restarting.
## Rate limiting
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` | `` (unset) | Maximum number of requests allowed per client IP within the window. The `rws` binary enforces the global limit only when this is set above `0` (decided at startup). `rate_limit::global()` in library code defaults to `1000`. |
| `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` | `60` | Sliding window length in seconds. |
Rate-limit values are hot-reloadable. Existing per-IP counters are preserved when
limits change — only the allowed maximum and window size are updated.
## Circuit breaker
Read by `RedisCircuitBreaker::from_env()` (with `RWS_REDIS_HOST`/`_PORT`/`_PASSWORD`;
see [Circuit breaker](/proxy/circuit-breaker/)). Config-driven proxy routes
don't use them.
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the circuit opens. |
| `RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS` | `30` | Seconds the circuit stays open before a half-open probe. |
| `RWS_CONFIG_CIRCUIT_BREAKER_MAX_HALF_OPEN_PROBES` | `1` | Probe requests allowed while half-open. |
## Logging
| Variable | Default | Options | Description |
|----------|---------|---------|-------------|
| `RWS_CONFIG_PROXY_PROTOCOL` | `false` | boolean | Every connection to the main listener starts with a PROXY protocol v1 or v2 header (HAProxy `send-proxy`, AWS NLB, GCP); its source address becomes the client address. Connections without one are closed. Since 18.3.0; read at startup. See [Behind a load balancer](/proxy/overview/#behind-an-l4-load-balancer-proxy-protocol). |
| `RWS_CONFIG_WATCH` | `false` | boolean | Reload when `rws.config.toml` (or `RWS_CONFIG_FILE`) or a configured TLS certificate or key changes — the same validated reload as `SIGHUP`, checked once a second (since 18.1.0). Read at startup. See [Hot reload](/features/hot-reload/#reloading-when-the-file-changes). |
| `RWS_CONFIG_ACCESS_LOG` | (stdout) | path | Append access-log lines to this file instead of stdout (since 18.1.0). Opened at the first line; `SIGUSR1` reopens it (for logrotate). Read at startup. See [Logs](/deployment/observability/#writing-to-a-file). |
| `RWS_CONFIG_ACCESS_LOG_MAX_SIZE` | `0` | bytes (`K`/`M`/`G` suffixes) | Rotate the access-log file when the next lines would take it past this size: `access.log` → `access.log.1` …. `0` never rotates (leave it to logrotate). |
| `RWS_CONFIG_ACCESS_LOG_KEEP` | `5` | integer | Rotated access-log files to keep; `0` empties the file instead of keeping any. |
| `RWS_CONFIG_LOG_FORMAT` | `json` | `json`, `combined`, `off` | Log format for access logs. `json` writes structured JSON. `combined` writes Combined Log Format (Apache/nginx-compatible). `off` (or `none`) writes no per-request line — e.g. behind a proxy that already logs. Hot-reloadable. |
The log format is hot-reloadable.
## Templates
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_CONFIG_TEMPLATE_DIR` | `templates` | Directory containing Tera HTML templates. Only relevant when the `tera` Cargo feature is enabled. |
## Database
Database variables are only used when the `model-sqlite`, `model-postgres`, or
`model-mysql` Cargo feature is enabled. They are not part of the `RWS_CONFIG_*`
namespace — they use a shorter `RWS_DB_*` prefix.
| Variable | Default | Description |
|----------|---------|-------------|
| `RWS_DB_BACKEND` | inferred | `sqlite`, `postgres`, or `mysql`. Inferred when exactly one `model-*` feature is compiled in; required otherwise. |
| `RWS_DB_HOST` | `localhost` | Database server hostname or IP address. |
| `RWS_DB_PORT` | `5432` | Database server port. The default matches PostgreSQL; MySQL uses `3306`. |
| `RWS_DB_USER` | `` (empty) | Database user name. |
| `RWS_DB_PASSWORD` | `` (empty) | Database password. |
| `RWS_DB_NAME` | `` (required) | Database name. No default — the server will fail to start if this is unset when the model feature is active. |
| `RWS_DB_POOL_SIZE` | `10` | Number of connections to pre-create in the connection pool at startup. |
:::caution[SQLite path]
For `model-sqlite`, `RWS_DB_NAME` is the file path of the SQLite database, for
example `RWS_DB_NAME=./data/app.db`. Use `:memory:` for an in-memory database
(useful in tests).
:::
## Example: production environment
```bash
# Server
export RWS_CONFIG_IP=0.0.0.0
export RWS_CONFIG_PORT=443
export RWS_CONFIG_THREAD_COUNT=500
# TLS
export RWS_CONFIG_TLS_CERT_FILE=/etc/ssl/certs/server.pem
export RWS_CONFIG_TLS_KEY_FILE=/etc/ssl/private/server.key
export RWS_CONFIG_HTTP_REDIRECT_PORT=80
# CORS — restrict to known origins
export RWS_CONFIG_CORS_ALLOW_ALL=false
export RWS_CONFIG_CORS_ALLOW_ORIGINS=https://app.example.com
export RWS_CONFIG_CORS_ALLOW_METHODS=GET,POST,PUT,DELETE
export RWS_CONFIG_CORS_ALLOW_HEADERS=content-type,authorization
# Rate limiting
export RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS=500
export RWS_CONFIG_RATE_LIMIT_WINDOW_SECS=60
# Logging
export RWS_CONFIG_LOG_FORMAT=json
```
---
# Config File (rws.config.toml)
<https://rws8.tech/configuration/config-file/>
`rws.config.toml` is an optional TOML file placed in the server's working
directory. It provides a convenient alternative to environment variables and CLI
flags. Any key set here overrides the built-in default and any system environment
variable, but can be overridden by a CLI flag.
The file is auto-detected at startup. Override the path with the
`RWS_CONFIG_FILE` environment variable (used for both server settings and
proxy routes):
```bash
RWS_CONFIG_FILE=/etc/rws/prod.toml rws
```
### Check the file before you deploy
`rws --check` validates the configuration without starting the server and
exits `0` if it has no errors, `1` otherwise — like `nginx -t`:
```bash
rws --check # the file rws would load
rws --check=/etc/rws/prod.toml # a specific file
rws --check --format=json # machine-readable, for CI and editors
```
With `--format=json` the report is one JSON object —
`{"path":…,"ok":…,"errors":[…],"warnings":[…]}` — with the same exit codes.
```text
error: [route.action] upsteam: unknown key — ignored
error: route 'api': upstream 'missing' is not a declared [[upstream]]
warning: [acme] without http_redirect_port: nothing answers the HTTP-01 challenge on port 80; set http_redirect_port = 80
rws.config.toml: 2 errors, 1 warning
```
It reports unknown keys anywhere in the file, values of the wrong type,
certificate and key files that are missing or don't load, ACME mistakes,
proxy routes that point at undeclared upstreams, unknown action types, IP
filter entries that aren't addresses, and auth secrets that aren't set.
Secrets and files are checked in the current environment, so run it where the
server runs. Proxy problems are also printed when the server starts.
The same checks are a library call, e.g. in a test that keeps a deployment's
config honest:
```rust
use rust_web_server::config_check;
#[test]
fn production_config_is_valid() {
let report = config_check::check_file("deploy/rws.config.toml");
assert!(report.is_ok(), "{}", report.render("deploy/rws.config.toml"));
}
```
`report.render_json(path)` gives the `--format=json` output.
### How keys map to settings
Every setting has an `RWS_CONFIG_*` environment variable. In the file, a key's
variable is its table name plus the key, upper-cased:
| In `rws.config.toml` | Setting |
|---|---|
| `port = 443` (top level) | `RWS_CONFIG_PORT` |
| `[cors]` `allow_all = false` | `RWS_CONFIG_CORS_ALLOW_ALL` |
| `[rate_limit]` `max_requests = 500` | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` |
| 2nd `[[virtual_host]]` block, `domain = ...` | `RWS_CONFIG_VIRTUAL_HOST_1_DOMAIN` |
Strings keep their exact contents (`csp = "default-src 'self'; img-src *"`
works); arrays become comma-separated lists. A key that doesn't correspond to
any setting is reported at startup — `Warning: unknown setting '[server] port'
in rws.config.toml — ignored` — rather than silently dropped. `[[route]]`,
`[[upstream]]`, `[[tcp_proxy]]`, `[[udp_proxy]]`, `[[ws_proxy]]`, and
`[middleware]` tables configure the proxy (below) and aren't settings.
An inline table is the same as a sub-table, here and in the proxy sections:
```toml
rate_limit = { max_requests = 1000, window_secs = 60 }
# same as
[rate_limit]
max_requests = 1000
window_secs = 60
```
:::note[Proxy mode]
When the file contains at least one `[[route]]` or `[[upstream]]` section, the
server starts in **proxy mode** (`ConfigDrivenApp`). The built-in static-file and
controller stack is used only as a fallback for unmatched requests.
:::
## Server settings (top level)
Basic server settings go at the top of the file, before any `[section]`
header. Each key maps to the `RWS_CONFIG_*` environment variable and the
`--flag` of the same name (`rws --help` lists them all).
```toml
ip = "0.0.0.0" # RWS_CONFIG_IP (default: 0.0.0.0)
port = 443 # RWS_CONFIG_PORT (default: 7878)
thread_count = 200 # RWS_CONFIG_THREAD_COUNT (default: 200, http1 build only)
tls_cert_file = "cert.pem" # RWS_CONFIG_TLS_CERT_FILE — enables HTTPS, HTTP/2, HTTP/3
tls_key_file = "key.pem" # RWS_CONFIG_TLS_KEY_FILE
http_redirect_port = 80 # RWS_CONFIG_HTTP_REDIRECT_PORT — HTTP → HTTPS redirect listener
request_allocation_size_in_bytes = 10000 # RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES — per-read chunk size
```
:::caution
Don't wrap these in a `[server]` table: keys are prefixed with their table's
name, so `[server] port = 443` becomes `--server-port`, which doesn't exist
and is ignored.
:::
## `[acme]`
Automatic HTTPS from Let's Encrypt or another ACME CA (`acme` feature,
included in the prebuilt binaries). Setting `domains` enables it. See
[Automatic HTTPS](/features/acme/) for how it works.
```toml
[acme]
domains = ["example.com", "www.example.com"] # RWS_CONFIG_ACME_DOMAINS
email = "ops@example.com" # RWS_CONFIG_ACME_EMAIL
staging = false # Let's Encrypt staging CA (testing)
directory = "" # ACME directory URL of another CA
ca_file = "" # extra root CA PEM for a private ACME CA
challenge_port = 80 # port the CA connects to for HTTP-01
cert_path = "" # default: tls_cert_file, else cert.pem
key_path = "" # default: tls_key_file, else key.pem
profile = "" # certificate profile, e.g. "shortlived"
# renew_before_days = 30 # default: at 2/3 of the lifetime
renew_check_interval_secs = 43200 # longest time between checks
account_key_path = "acme_account.key" # ACME account key, kept between restarts
```
## `[cors]`
CORS policy applied to every response. All fields are hot-reloadable.
```toml
[cors]
# Allow every origin, with credentials (default false since 18.0.0). When
# true, the specific allow_* fields below are ignored.
allow_all = false
# Restrict to specific origins (comma-separated). Used when allow_all = false.
allow_origins = "https://app.example.com,https://admin.example.com"
# Allowed HTTP methods (comma-separated).
allow_methods = "GET,POST,PUT,DELETE,OPTIONS"
# Allowed request headers, in lowercase (comma-separated).
allow_headers = "content-type,authorization,x-api-key"
# Whether to allow cookies and credentials with cross-origin requests.
allow_credentials = "true"
# Response headers the browser may expose to JavaScript (comma-separated, lowercase).
expose_headers = "x-request-id,x-rate-limit-remaining"
# Preflight cache duration in seconds (default: 86400 = 24 hours).
max_age = 86400
```
## `[rate_limit]`
Global rate limit per client IP: requests over the budget get
`429 Too Many Requests`. Off unless `max_requests` is set — enabling it needs a
restart; changing the numbers afterwards is hot-reloadable. For per-route
limits in proxy mode use `[route.middleware] rate_limit` instead.
```toml
[rate_limit]
max_requests = 1000 # Requests allowed per window; 0 or unset = no global limit
window_secs = 60 # Sliding window length in seconds (default: 60)
```
## Other settings
```toml
log_format = "json" # RWS_CONFIG_LOG_FORMAT: "json" (default) | "combined" | "off"
watch = true # RWS_CONFIG_WATCH: reload when this file or a certificate changes (default false)
proxy_protocol = true # RWS_CONFIG_PROXY_PROTOCOL: connections start with a PROXY protocol header (default false)
access_log = "/var/log/rws/access.log" # RWS_CONFIG_ACCESS_LOG: write the access log here instead of stdout
access_log_max_size = "100M" # RWS_CONFIG_ACCESS_LOG_MAX_SIZE: rotate past this size; 0 (default) = never (use logrotate + SIGUSR1)
access_log_keep = 5 # RWS_CONFIG_ACCESS_LOG_KEEP: rotated files kept (access.log.1 … .5)
max_body_size_in_bytes = 10485760 # RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES: 413 above this; 0 = unlimited
open_file_cache_secs = 1 # RWS_CONFIG_OPEN_FILE_CACHE_SECS: reuse open static files this long; 0 = off
open_file_cache_strict = true # RWS_CONFIG_OPEN_FILE_CACHE_STRICT: false = trust size/mtime for that long (no fstat per request)
runtime = "per-core" # RWS_CONFIG_RUNTIME: one single-threaded runtime per core (SO_REUSEPORT; the binary's default since 18.0.0); "shared" = one multi-threaded runtime
csp = "default-src 'self'" # RWS_CONFIG_CSP: Content-Security-Policy header
security_headers = true # RWS_CONFIG_SECURITY_HEADERS: X-Frame-Options, Referrer-Policy, Permissions-Policy, default CSP
nanos_headers = false # RWS_CONFIG_NANOS_HEADERS: Date-/Last-Modified-Unix-Epoch-Nanos
static_cache_control = "no-cache" # RWS_CONFIG_STATIC_CACHE_CONTROL: Cache-Control for static files
client_hints = false # RWS_CONFIG_CLIENT_HINTS: send Accept-CH / Critical-CH
tls_client_ca_file = "/etc/rws/clients-ca.pem" # RWS_CONFIG_TLS_CLIENT_CA_FILE: require client certs (mTLS)
template_dir = "templates" # RWS_CONFIG_TEMPLATE_DIR
serve_dotfiles = false # RWS_CONFIG_SERVE_DOTFILES: serve .git/, .env, … (.well-known/ always)
spa_fallback = "index.html" # RWS_CONFIG_SPA_FALLBACK: file served for client-side routes
spa_fallback_exclude_prefixes = ["/api"] # RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES
[circuit_breaker] # RWS_CONFIG_CIRCUIT_BREAKER_*: read by RedisCircuitBreaker::from_env()
failure_threshold = 5
recovery_secs = 30
max_half_open_probes = 1
```
See [Environment Variables](/configuration/env-vars/) for what each one does.
## Request limits
Fixed limits that apply whatever the configuration:
| Limit | Value | Over the limit |
|---|---|---|
| Request head (request line + headers), HTTP/1.1 | 64 KiB | `431 Request Header Fields Too Large` |
| Header list, HTTP/2 and HTTP/3 | 64 KiB | stream reset |
| Concurrent streams per HTTP/2 connection | 250 | stream refused |
| Idle time between reads | 30 s | connection closed |
| HTTP/2 connection with nothing running and nothing received | 30 s | `GOAWAY(NO_ERROR)`, connection closed |
| HTTP/2 stream resets, the client's or provoked by it (rapid reset, MadeYouReset) | 1000 per 10 s | `GOAWAY(ENHANCE_YOUR_CALM)` |
| Whole request head, from its first byte (HTTP/1.1) | 30 s | `408 Request Timeout` |
| TLS handshake | 10 s | connection closed |
The request body limit is `max_body_size_in_bytes` above. On HTTP/1.1, bodies
may be sent with `Content-Length` or `Transfer-Encoding: chunked`; a request
that carries both, or two different `Content-Length` values, is rejected with
`400` so `rws` and a proxy in front of it can never disagree about where a
request ends.
For the same reason an HTTP/1.1 request head is read strictly (17.113.2) and
answered `400` when it has no `Host` or more than one, a method not in upper
case (`get`), a version other than `HTTP/1.0`/`HTTP/1.1`, a folded header
line (starting with a space or tab), whitespace before a header's colon, or a
header name with characters a token can't have. `Connection` is read token by
token: `Connection: close, TE` closes the connection after the response.
## `[[virtual_host]]`
Repeat this section for each domain that needs its own TLS certificate. The SNI
resolver selects the right certificate automatically at handshake time.
```toml
[[virtual_host]]
domain = "example.com"
cert_file = "/etc/ssl/example.com.pem"
key_file = "/etc/ssl/example.com.key"
[[virtual_host]]
domain = "api.example.com"
cert_file = "/etc/ssl/api.example.com.pem"
key_file = "/etc/ssl/api.example.com.key"
```
## `[[upstream]]`
Defines a named backend pool. Upstreams are referenced by name from routes.
```toml
[[upstream]]
name = "api"
backends = ["localhost:3000", "localhost:3001"]
strategy = "round_robin" # "round_robin" | "random" | "ip_hash" | "least_connections" (default: round_robin)
# tls_ca_file = "/etc/rws/internal-ca.pem" # TLS backends: trust this CA instead of the public roots
# tls_client_cert_file = "/etc/rws/client.pem" # TLS backends that require a client certificate (mutual TLS),
# tls_client_key_file = "/etc/rws/client.key" # with its private key — since 18.3.0
[upstream.health_check]
path = "/health" # HTTP path polled on each backend (default: /health)
interval_secs = 30 # Poll interval (default: 30)
timeout_ms = 5000 # Connect + read timeout per poll (default: 5000)
healthy_threshold = 2 # Consecutive passes before marking healthy (default: 2)
unhealthy_threshold = 3 # Consecutive failures before marking unhealthy (default: 3)
```
When the health checker removes all backends, the route returns `502 Bad Gateway`
until at least one backend recovers.
## `[[route]]`
Each `[[route]]` entry maps a set of match criteria to an action and an optional
per-route middleware stack. Routes are evaluated in declaration order; the first
match wins.
### `[route.match]`
All criteria are optional. A request must satisfy every criterion that is set.
```toml
[[route]]
name = "api-proxy"
[route.match]
host = "api.example.com" # SNI hostname or Host header (exact match)
path = "/api/*" # Prefix match when ending with *; exact match otherwise
method = "POST" # HTTP method (case-insensitive; matches any if omitted)
content_type = "application/json" # Content-Type prefix match
```
### `[route.action]` — proxy
Forward requests to a named upstream pool.
```toml
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api" # Must match a [[upstream]] name
connect_timeout_ms = 5000 # (default: 5000)
read_timeout_ms = 30000 # (default: 30000)
strip_path_prefix = "/api" # Strip this prefix before forwarding (optional)
add_path_prefix = "/v2" # Add this prefix after stripping (optional)
```
### `[route.action]` — grpc
Forward gRPC traffic over HTTP/2 to a named upstream.
```toml
[route.action]
type = "grpc"
[route.action.grpc]
upstream = "grpc-backend"
connect_timeout_ms = 5000
read_timeout_ms = 30000
```
### `[route.action]` — static
Serve static files from a directory on disk.
```toml
[route.action]
type = "static"
[route.action.static]
root = "./public"
index = ["index.html", "index.htm"]
```
### `[route.action]` — redirect
Issue an HTTP redirect.
```toml
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://new.example.com$path" # $path is replaced with the request URI
status = 301 # (default: 301)
```
### `[route.action]` — respond
Return a fixed response body without proxying.
```toml
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "{\"ok\":true}"
content_type = "application/json" # (default: text/plain)
```
### `[route.action]` — mcp
Accepted by the parser, but not served from the config file: `rws --check`
warns, and matching requests fall through to the static files. Mount MCP in
code instead (see [MCP](/mcp/overview/)).
```toml
[route.action]
type = "mcp"
```
### `[route.middleware]`
Per-route middleware applied before the action. All sub-sections are optional.
```toml
[route.middleware]
timeout_ms = 5000 # 504 Gateway Timeout past this (0/unset = none)
max_body_size = 1048576 # 413 above this many bytes (0/unset = only the global limit)
[route.middleware.rate_limit]
max_requests = 100
window_secs = 60
[route.middleware.cache]
ttl_secs = 300
vary_by = ["Accept-Encoding", "Authorization"]
[route.middleware.auth]
type = "bearer" # "bearer" | "jwt" | "basic"
token_env = "API_TOKEN" # env var name holding the expected token (bearer)
# secret_env = "JWT_SECRET" # env var name holding the JWT secret (jwt)
# htpasswd_file = "users.htpasswd" # path to htpasswd file (basic)
[route.middleware.ip_filter]
allow = ["10.0.0.0/8", "192.168.1.0/24"]
deny = ["10.0.0.5"]
```
A top-level `[middleware]` table (same keys and sub-tables as
`[route.middleware]`) wraps every route and the static fallback.
#### Request rewrite rules
```toml
[[route.middleware.rewrite.request]]
type = "set_header"
name = "X-Forwarded-Proto"
value = "https"
[[route.middleware.rewrite.request]]
type = "strip_path_prefix"
prefix = "/api"
[[route.middleware.rewrite.request]]
type = "add_path_prefix"
prefix = "/v2"
[[route.middleware.rewrite.request]]
type = "remove_header"
name = "X-Internal-Secret"
[[route.middleware.rewrite.request]]
type = "set_uri"
value = "/health"
```
#### Response rewrite rules
```toml
[[route.middleware.rewrite.response]]
type = "set_header"
name = "X-Powered-By"
value = "rws"
[[route.middleware.rewrite.response]]
type = "remove_header"
name = "Server"
[[route.middleware.rewrite.response]]
type = "set_status"
code = 200
reason = "OK"
[[route.middleware.rewrite.response]]
type = "replace_body"
from = "old-domain.com"
to = "new-domain.com"
```
## `[[tcp_proxy]]`
Standalone L4 TCP proxy. Binds its own port and relays bytes bidirectionally
between the client and a backend. Runs in a background thread and is independent
of the HTTP server.
```toml
[[tcp_proxy]]
name = "db-proxy"
listen = "0.0.0.0:5433" # Address to bind
backends = ["db-primary:5432", "db-replica:5432"]
connect_timeout_ms = 5000 # (default: 5000)
```
## `[[udp_proxy]]`
Standalone UDP datagram proxy. Each datagram is forwarded to a backend and the
reply is relayed back to the originating client.
```toml
[[udp_proxy]]
name = "dns-proxy"
listen = "0.0.0.0:5353"
backends = ["8.8.8.8:53", "8.8.4.4:53"]
reply_timeout_ms = 5000 # How long to wait for a reply (default: 5000)
buffer_size = 65536 # Datagram buffer in bytes (default: 65536)
```
## `[[ws_proxy]]`
Standalone WebSocket proxy. Handles the HTTP upgrade handshake then relays raw
bytes bidirectionally between client and backend.
```toml
[[ws_proxy]]
name = "ws-backend"
listen = "0.0.0.0:8080"
backends = ["localhost:8081"]
connect_timeout_ms = 5000 # (default: 5000)
read_timeout_ms = 30000 # (default: 30000)
[ws_proxy.health_check] # optional, same keys as [upstream.health_check]
path = "/health"
```
## Complete example
```toml
ip = "0.0.0.0"
port = 443
http_redirect_port = 80
[acme]
domains = ["example.com", "api.example.com"]
email = "ops@example.com"
[cors]
allow_all = false
allow_origins = "https://app.example.com"
allow_methods = "GET,POST,PUT,DELETE"
allow_headers = "content-type,authorization"
max_age = 86400
[rate_limit]
max_requests = 500
window_secs = 60
[[virtual_host]]
domain = "example.com"
cert_file = "/etc/ssl/certs/example.pem"
key_file = "/etc/ssl/private/example.key"
[[upstream]]
name = "api"
backends = ["localhost:3000", "localhost:3001"]
strategy = "round_robin"
[upstream.health_check]
path = "/health"
interval_secs = 15
healthy_threshold = 2
unhealthy_threshold = 3
[[route]]
name = "api"
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api"
strip_path_prefix = "/api"
[route.middleware.rate_limit]
max_requests = 100
window_secs = 60
[[route]]
name = "health"
[route.match]
path = "/ping"
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "pong"
```
---
# CLI Arguments
<https://rws8.tech/configuration/cli-args/>
Command-line arguments are the highest-priority configuration layer — they
override defaults, system environment variables, and `rws.config.toml`. Every
flag maps directly to a `RWS_CONFIG_*` environment variable; setting the flag is
exactly equivalent to setting that variable before the process starts.
Flags use the `--long-form=value` syntax. A single-character short form is also
supported with `-x=value`.
```bash
rws [--flag=value ...]
```
## Server
| Flag | Short | Type | Default | Maps to |
|------|-------|------|---------|---------|
| `--ip=<addr>` | `-i` | string | `0.0.0.0` | `RWS_CONFIG_IP` |
| `--port=<n>` | `-p` | integer | `7878` | `RWS_CONFIG_PORT` |
| `--thread-count=<n>` | `-t` | integer | `200` | `RWS_CONFIG_THREAD_COUNT` |
| `--request-allocation-size-in-bytes=<n>` | `-r` | integer | `10000` | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
```bash
# Listen on all interfaces on port 443 with 500 worker threads
rws --ip=0.0.0.0 --port=443 --thread-count=500
```
## TLS
| Flag | Short | Type | Default | Maps to |
|------|-------|------|---------|---------|
| `--tls-cert-file=<path>` | `-s` | path | `` (empty) | `RWS_CONFIG_TLS_CERT_FILE` |
| `--tls-key-file=<path>` | `-k` | path | `` (empty) | `RWS_CONFIG_TLS_KEY_FILE` |
Setting both `--tls-cert-file` and `--tls-key-file` enables HTTPS, HTTP/2, and
HTTP/3. The server falls back to plain HTTP/1.1 when either flag is absent.
```bash
rws --tls-cert-file=cert.pem --tls-key-file=key.pem
```
| Flag | Short | Type | Default | Maps to |
|------|-------|------|---------|---------|
| `--http-redirect-port=<n>` | — | integer | `` (empty) | `RWS_CONFIG_HTTP_REDIRECT_PORT` |
:::note[mTLS]
`RWS_CONFIG_TLS_CLIENT_CA_FILE` has no CLI flag. Set it via the environment
variable or `tls_client_ca_file` in `rws.config.toml`.
:::
## ACME
Long form only (`acme` feature). See [Automatic HTTPS](/features/acme/).
| Flag | Type | Default | Maps to |
|------|------|---------|---------|
| `--acme-domains=<list>` | string | `` (empty) | `RWS_CONFIG_ACME_DOMAINS` |
| `--acme-email=<addr>` | string | `` (empty) | `RWS_CONFIG_ACME_EMAIL` |
| `--acme-staging=<bool>` | bool | `false` | `RWS_CONFIG_ACME_STAGING` |
| `--acme-directory=<url>` | string | `` (empty) | `RWS_CONFIG_ACME_DIRECTORY` |
| `--acme-ca-file=<path>` | path | `` (empty) | `RWS_CONFIG_ACME_CA_FILE` |
| `--acme-challenge-port=<n>` | integer | `80` | `RWS_CONFIG_ACME_CHALLENGE_PORT` |
| `--acme-cert-path=<path>` | path | `` (empty) | `RWS_CONFIG_ACME_CERT_PATH` |
| `--acme-key-path=<path>` | path | `` (empty) | `RWS_CONFIG_ACME_KEY_PATH` |
| `--acme-profile=<name>` | string | `` (empty) | `RWS_CONFIG_ACME_PROFILE` |
Settings not listed on this page have no flag; set them through the
environment or `rws.config.toml`.
## CORS
| Flag | Short | Type | Default | Maps to |
|------|-------|------|---------|---------|
| `--cors-allow-all=<bool>` | `-a` | bool | `false` | `RWS_CONFIG_CORS_ALLOW_ALL` |
| `--cors-allow-origins=<list>` | `-o` | string | `` (empty) | `RWS_CONFIG_CORS_ALLOW_ORIGINS` |
| `--cors-allow-methods=<list>` | `-m` | string | `` (empty) | `RWS_CONFIG_CORS_ALLOW_METHODS` |
| `--cors-allow-headers=<list>` | `-h` | string | `` (empty) | `RWS_CONFIG_CORS_ALLOW_HEADERS` |
| `--cors-allow-credentials=<bool>` | `-c` | bool | `` (empty) | `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` |
| `--cors-expose-headers=<list>` | `-e` | string | `` (empty) | `RWS_CONFIG_CORS_EXPOSE_HEADERS` |
| `--cors-max-age=<seconds>` | `-g` | integer | `86400` | `RWS_CONFIG_CORS_MAX_AGE` |
`<list>` values are comma-separated strings. Header name lists should be
lowercase.
```bash
# Disable the allow-all default and restrict to specific origins
rws \
--cors-allow-all=false \
--cors-allow-origins=https://app.example.com,https://admin.example.com \
--cors-allow-methods=GET,POST,PUT,DELETE \
--cors-allow-headers=content-type,authorization \
--cors-allow-credentials=true \
--cors-expose-headers=x-request-id \
--cors-max-age=3600
```
:::caution[allow-all takes precedence]
When `--cors-allow-all=true` (the default), the specific `--cors-allow-origins`,
`--cors-allow-methods`, and `--cors-allow-headers` flags are ignored. Set
`--cors-allow-all=false` to activate fine-grained CORS control.
:::
## Informational flags
These print and exit without starting the server.
| Flag | What it does |
|------|--------------|
| `--version`, `-V` | Print `rws <version>` |
| `--help` | Print usage and every flag with its environment variable |
| `--check[=<file>]` | Validate the config file (default: `RWS_CONFIG_FILE`, else `./rws.config.toml`); exit `0` if no errors, `1` otherwise |
| `--format=json` | With `--check`, print the report as JSON |
`--windows-service=<directory>` (Windows) runs rws as a service of the Service Control Manager, serving `<directory>` — see [Windows](/deployment/windows/).
## Quick-start examples
### Development server (HTTP only)
```bash
cargo run -- --ip=127.0.0.1 --port=7878
```
### HTTPS with HTTP/2 and HTTP/3
```bash
cargo run -- --tls-cert-file=cert.pem --tls-key-file=key.pem
```
### Locked-down CORS for production
```bash
rws \
--port=443 \
--tls-cert-file=/etc/ssl/server.pem \
--tls-key-file=/etc/ssl/server.key \
--cors-allow-all=false \
--cors-allow-origins=https://app.example.com \
--cors-allow-methods=GET,POST \
--cors-allow-headers=content-type,authorization \
--request-allocation-size-in-bytes=65536
```
### Override a single value from the config file
CLI flags always win over `rws.config.toml`. Use this pattern to temporarily
change a setting without editing the file:
```bash
# rws.config.toml has port = 7878; override to 9090 for this run only
rws --port=9090
```
## Full flag reference
```
rws [OPTIONS]
OPTIONS:
-i, --ip=<addr>
IP address to bind (default: 0.0.0.0)
-p, --port=<n>
TCP port to listen on (default: 7878)
-t, --thread-count=<n>
Worker thread pool size (http1 build only; default: 200)
-r, --request-allocation-size-in-bytes=<n>
Read buffer size per request in bytes (default: 10000)
-s, --tls-cert-file=<path>
Path to PEM certificate chain; enables TLS when set
-k, --tls-key-file=<path>
Path to PEM private key; enables TLS when set
-a, --cors-allow-all=<bool>
Allow all CORS origins (default: true)
-o, --cors-allow-origins=<list>
Comma-separated allowed origins
-m, --cors-allow-methods=<list>
Comma-separated allowed HTTP methods
-h, --cors-allow-headers=<list>
Comma-separated allowed request headers (lowercase)
-c, --cors-allow-credentials=<bool>
Allow credentials in CORS requests
-e, --cors-expose-headers=<list>
Comma-separated response headers exposed to the browser (lowercase)
-g, --cors-max-age=<seconds>
Preflight cache duration in seconds (default: 86400)
--http-redirect-port=<n>
Plain-HTTP port that 301-redirects to HTTPS (and answers ACME HTTP-01)
--acme-domains, --acme-email, --acme-staging, --acme-directory,
--acme-ca-file, --acme-challenge-port, --acme-cert-path, --acme-key-path,
--acme-profile
ACME settings (see above)
-V, --version Print the version and exit
--help Print usage and exit
--check[=<file>] Validate the configuration and exit (--format=json for JSON)
--windows-service=<dir>
(Windows) Run as a service serving <dir>
```
---
# Building Apps Overview
<https://rws8.tech/building-apps/overview/>
## Request lifecycle
Every request travels the same path regardless of which routing style you use:
```
main.rs
└─ Server::setup() — bind TCP listener
└─ Server::run_tls() — accept loop (plain HTTP via run_plain when no certificate);
│ TLS handshake; ALPN picks HTTP/2 or HTTP/1.1
└─ one task per connection — keep-alive loop; Request::parse()
└─ app.execute() — your routing logic returns Result,
│ run on tokio's blocking thread pool (or inline, see below)
└─ compression, metrics, Connection header, write response
```
The connection handler calls `app.execute(request, connection)` where `app` is any value that implements the `Application` trait. `execute` is synchronous and may block (read files, call a database or upstream): the server runs it on tokio's blocking thread pool, so a slow handler never stalls other connections. Requests for which `Application::runs_inline` returns `true` (the built-in `App`'s GET/HEAD — health, metrics, static files) skip that handoff and run directly on the event loop; see [Middleware](/building-apps/middleware/#running-on-the-event-loop-runs_inline). (The `http1`-only build has no async runtime; there each connection is served by `Server::process()` on a fixed-size worker thread pool.) The three routing styles differ only in how you implement or compose that `Application`.
`Server::setup()` reads the IP, port and thread count from the layered configuration (defaults → env vars → `rws.config.toml` → CLI args; see [Configuration](/configuration/overview/)) and returns the bound listener plus a thread pool. The async builds then pass your app to `Server::run_tls` (see [Quick Start](/getting-started/quick-start/)); the `http1`-only build uses the blocking `Server::run`:
```rust
use rust_web_server::server::Server;
fn main() {
let (listener, pool) = match Server::setup() {
Ok(v) => v,
Err(e) => { eprintln!("{}", e); return; }
};
let app = MyApp::new(); // any type implementing Application + Clone
Server::run(listener, pool, app);
}
```
## Three ways to add routes
### 1. Controller trait
The lowest-level option. Implement two static methods on a unit struct and register the struct in `App::execute`.
```rust
use rust_web_server::controller::Controller;
use rust_web_server::request::{METHOD, Request};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::server::ConnectionInfo;
pub struct PingController;
impl Controller for PingController {
fn is_matching(request: &Request, _conn: &ConnectionInfo) -> bool {
request.method == METHOD.get && request.request_uri == "/ping"
}
fn process(_req: &Request, mut response: Response, _conn: &ConnectionInfo) -> Response {
response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
response.content_range_list = vec![
Range::get_content_range(b"pong".to_vec(), MimeType::TEXT_PLAIN.to_string())
];
response
}
}
```
Controllers are checked in declaration order inside `App::execute`; the first `is_matching` that returns `true` wins.
Use this style when you need precise control over matching logic that path patterns cannot express, or when you are extending the built-in controller chain.
### 2. Router
A fluent, path-based router with named parameters and wildcards. Call `router.handle(request, connection)` from inside any `Application::execute` implementation.
```rust
use rust_web_server::router::{Router, PathParams};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
let router = Router::new()
.get("/hello", |_req, _params, _conn| {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(b"hello".to_vec(), MimeType::TEXT_PLAIN.to_string())
];
r
})
.get("/users/:id", |_req, params, _conn| {
let id = params.get("id").unwrap_or("unknown");
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(
format!("user {}", id).into_bytes(),
MimeType::TEXT_PLAIN.to_string(),
)
];
r
});
```
Use `Router` directly when you need to compose multiple routers (for example, one per virtual host) inside a custom `Application::execute`.
### 3. App::with_state (recommended for new code)
The highest-level option. `App::with_state(S)` creates an `AppWithState` that carries your state in an `Arc` and exposes `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` builder methods. Handlers receive `&S` directly.
```rust
use rust_web_server::app::App;
use rust_web_server::routes;
struct Db { url: String }
let app = routes! {
App::with_state(Db { url: "postgres://localhost/mydb".to_string() }),
GET "/users" => list_users,
POST "/users" => create_user,
GET "/users/:id" => get_user,
};
```
Unmatched requests fall through to the built-in `App` controller chain (static files, `/healthz`, `/readyz`, `/metrics`, 404).
## When to use which approach
| Situation | Recommended style |
|---|---|
| New CRUD endpoints with shared state | `App::with_state` + `routes!` |
| Virtual-host routing or path conditions | `Router::with_host` |
| Extend or override built-in behaviour | `Controller` trait |
| Add cross-cutting concerns | Middleware via `.wrap(layer)` |
## Application variants at a glance
- `App` — zero-config built-in app; `App::new()`.
- `AppWithState` — state-aware sync router; `App::with_state(S)`.
- `AsyncAppWithState` — same but handlers are `async fn`; requires the `http2` feature.
- `WithMiddleware` — wraps any `Application` with a middleware stack; `app.wrap(layer)`.
- `McpServer` — serves the MCP Streamable HTTP protocol; `app.mcp(name, version)`.
---
# Controllers
<https://rws8.tech/building-apps/controllers/>
## The Controller trait
`Controller` is defined in `src/controller/mod.rs` and has two static methods:
```rust
pub trait Controller {
fn is_matching(request: &Request, connection: &ConnectionInfo) -> bool;
fn process(request: &Request, response: Response, connection: &ConnectionInfo) -> Response;
}
```
- `is_matching` — called in declaration order inside `App::execute`; the first controller that returns `true` wins.
- `process` — receives the partially-built `response` (already populated with standard headers from `Header::get_header_list`) and must return it with a status code and body.
## Complete minimal example
```rust
use rust_web_server::controller::Controller;
use rust_web_server::request::{METHOD, Request};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::server::ConnectionInfo;
pub struct HelloController;
impl Controller for HelloController {
fn is_matching(request: &Request, _conn: &ConnectionInfo) -> bool {
request.method == METHOD.get && request.request_uri == "/hello"
}
fn process(_req: &Request, mut response: Response, _conn: &ConnectionInfo) -> Response {
response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
response.content_range_list = vec![
Range::get_content_range(
b"Hello, world!".to_vec(),
MimeType::TEXT_PLAIN.to_string(),
)
];
response
}
}
```
## Registering a controller
Controllers are hardcoded in `App::execute` inside `src/app/mod.rs`. To add your own, you currently need a custom `Application` implementation that checks your controller before (or after) the built-in list:
```rust
use rust_web_server::application::Application;
use rust_web_server::app::App;
use rust_web_server::controller::Controller;
use rust_web_server::header::Header;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::server::ConnectionInfo;
use rust_web_server::core::New;
pub struct MyApp;
impl Application for MyApp {
fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result {
// Check custom controllers first
if HelloController::is_matching(request, connection) {
let header_list = Header::get_header_list(request);
let response = Response::get_response(
STATUS_CODE_REASON_PHRASE.n501_not_implemented,
Some(header_list),
None,
);
return Ok(HelloController::process(request, response, connection));
}
// Fall through to the built-in controller chain
App::new().execute(request, connection)
}
}
```
## Built-in controllers
These are registered in `App::execute` in this order (first match wins):
| Controller | Matches |
|---|---|
| `IndexController` | `GET /` — serves `index.html` from the static directory |
| `StyleController` | `GET /style.css` |
| `ScriptController` | `GET /script.js` |
| `DirectoryListingAssetsController` | `GET /rws-directory-listing.css` / `.js` — same-origin CSS/JS for the directory listing page below |
| `FileUploadInitiateController` | `POST /file-upload/initiate` |
| `FormUrlEncodedEnctypePostMethodController` | `POST /form-url-encoded-enctype-post-method` with `application/x-www-form-urlencoded` |
| `FormGetMethodController` | `GET /form-get-method` (with query parameters) |
| `FormMultipartEnctypePostMethodController` | `POST /form-multipart-enctype-post-method` with `multipart/form-data` |
| `HealthController` | `GET /healthz` — returns `200 OK` |
| `ReadyController` | `GET /readyz` — returns `200` when `SERVER_READY` is set, `503` otherwise |
| `MetricsController` | `GET /metrics` — Prometheus text format |
| `FaviconController` | `GET /favicon.svg` |
| `StaticResourceController` | Any `GET` for a file found under the static directory; a directory with no `index.html` renders a directory listing page instead of falling through to `404` |
| `NotFoundController` | Catch-all — returns `404 Not Found` |
:::note[Prefer App::with_state for new routes]
For new application routes, `App::with_state(S).get(pattern, handler)` or the `routes!` macro is less boilerplate than writing a custom `Controller` implementation and wiring it into a custom `Application`. Use `Controller` when you need matching logic that path patterns cannot express, or when you need to intercept built-in behaviour.
:::
---
# Routing
<https://rws8.tech/building-apps/routing/>
## routes! macro
The `routes!` macro is the recommended way to declare routes for new code. It builds an `AppWithState` (or any builder that exposes `.get()`, `.post()`, etc.) from a declarative table.
```rust
use rust_web_server::app::App;
use rust_web_server::routes;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
struct Db;
fn list_users(_req: &Request, _params: &PathParams, _conn: &ConnectionInfo, _db: &Db) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
fn create_user(_req: &Request, _params: &PathParams, _conn: &ConnectionInfo, _db: &Db) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n201_created.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string();
r
}
let app = routes! {
App::with_state(Db),
GET "/users" => list_users,
POST "/users" => create_user,
};
```
Syntax:
```text
routes! {
<builder_expression>,
METHOD "pattern" => handler_or_closure,
...
}
```
Valid methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` (all caps). A trailing comma after the last route is optional.
## Router — direct usage
Use `Router` directly when you need to compose multiple routers or call `handle` inside a custom `Application::execute`.
```rust
use rust_web_server::router::{Router, PathParams};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
let router = Router::new()
.get("/users/:id", |_req, params, _conn| {
let id = params.get("id").unwrap_or("unknown");
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(
format!("user {}", id).into_bytes(),
MimeType::TEXT_PLAIN.to_string(),
)];
r
})
.delete("/users/:id", |_req, params, _conn| {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n204_no_content.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string();
r
});
// Inside Application::execute:
// if let Some(response) = router.handle(request, connection) {
// return Ok(response);
// }
```
`router.handle(request, connection)` returns `Some(Response)` on the first match, `None` if no route matches. Routes are tried in registration order.
## Pattern syntax
| Syntax | Description | Example |
|---|---|---|
| `/literal` | Exact path segment | `/users` |
| `/:name` | Named segment — captures one segment | `/users/:id` |
| `/*name` | Wildcard — captures everything after the prefix | `/files/*path` |
Rules:
- A wildcard (`*name`) must be the **last** segment in a pattern.
- The query string is stripped before matching; only the path is used.
- Patterns are matched segment-by-segment; `/users` does not match `/users/42`.
```rust
// /users/42/posts/7 → id="42", post_id="7"
router.get("/users/:id/posts/:post_id", handler);
// /files/a/b/c.txt → path="a/b/c.txt"
router.get("/files/*path", handler);
```
## PathParams extraction
Inside a handler, call `params.get("name")` to retrieve a named segment value. It returns `Option<&str>`.
```rust
.get("/articles/:slug", |_req, params, _conn| {
let slug = params.get("slug").unwrap_or("unknown");
// use slug ...
Response::new()
})
```
## Virtual-host routing
Call `.with_host("example.com")` before registering routes to restrict a router to requests whose SNI hostname (TLS) or `Host` header (plain HTTP) matches that value.
```rust
let api_router = Router::new()
.with_host("api.example.com")
.get("/status", api_status_handler);
let www_router = Router::new()
.with_host("www.example.com")
.get("/", home_handler);
// In Application::execute:
// api_router.handle(request, connection)
// .or_else(|| www_router.handle(request, connection))
```
`handle()` returns `None` immediately when `.with_host()` is set and the incoming request's hostname does not match, so composing multiple host-scoped routers is safe and efficient.
## Relationship to `App`'s built-in routes
`App`'s built-in controllers — static file serving, `/healthz`/`/readyz`/`/metrics`, form handling, the 404 fallback — deliberately don't go through `Router`. That set is small, static, and known at compile time, so a fixed if-chain is simpler and just as fast as a segment matcher would be there. `Router` is for **your** routes, with dynamic path params and wildcards a fixed if-chain can't express cleanly.
`AppWithState` and `AsyncAppWithState` already compose the two: they hold a `Router` internally, try it first, and fall through to `App`'s built-in controller chain for anything the router doesn't match — so static files, health checks, and metrics keep working automatically even in a fully custom, stateful app.
## Attribute macros
The `macros` feature also adds `#[get("/path")]`, `#[post("/path")]`, `#[put]`, `#[patch]`, `#[delete]` and the generic `#[route(GET, "/path")]`. They only add a `Route: METHOD /path` doc comment and leave the function unchanged — you still register the handler with `routes!` or a `.get()` builder call:
```toml
# Cargo.toml
rust-web-server = { version = "18", features = ["macros"] }
```
```rust
use rust_web_server::get;
#[get("/items")]
fn list_items(_: &Request, _: &PathParams, _: &ConnectionInfo, _: &Db) -> Response {
// ...
}
```
See the [API reference](/reference/api/) for the full list of public types.
---
# Request & Response
<https://rws8.tech/building-apps/request-response/>
## Request
`Request` is defined in `src/request/mod.rs`:
```rust
pub struct Request {
pub method: String, // "GET", "POST", etc.
pub request_uri: String, // "/search?q=rust"
pub http_version: String, // "HTTP/1.1"
pub headers: Vec,
pub body: Body, // the body (since 18.0.0; was Vec<u8>)
}
```
### The body
`request.body` is a `request::Body` (since 18.0.0). It dereferences to
`&[u8]`, so `&request.body[..]`, `request.body.len()` and
`String::from_utf8_lossy(&request.body)` work as before; `.to_vec()` gives a
`Vec<u8>`. Build one from bytes or text with `.into()`
(`body: b"data".to_vec().into()`, `body: "text".into()`) or `Default::default()`
for none.
The server reads the whole body before your handler runs, as it always has.
A `Body` can also be a stream (`Body::stream(reader)`, `body.is_stream()`):
read it with `std::io::Read` (`(&request.body).read(..)`), `into_reader()`,
or all at once with `body_bytes()`.
### METHOD constants
Compare `request.method` against the `METHOD` constant instead of string literals:
```rust
use rust_web_server::request::{METHOD, Request};
fn is_get(request: &Request) -> bool {
request.method == METHOD.get
}
```
Available fields: `METHOD.get`, `METHOD.head`, `METHOD.post`, `METHOD.put`, `METHOD.delete`, `METHOD.connect`, `METHOD.options`, `METHOD.trace`, `METHOD.patch`.
### Helper methods
```rust
// Parse the query string into a HashMaplet query: Option> = request.get_query().unwrap();
// Extract just the path, stripping the query string
let path: String = request.get_path().unwrap();
// Get a specific header (case-insensitive)
let content_type: Option<&Header> = request.get_header("Content-Type".to_string());
// Extract the Host header's domain component
let domain: Option = request.get_domain().unwrap();
```
`request.header(name)` is the allocation-free variant of `get_header`: it takes a `&str` and also matches case-insensitively.
```rust
use rust_web_server::header::Header;
match request.header(Header::_AUTHORIZATION) {
Some(h) => println!("Authorization: {}", h.value),
None => { /* not present */ }
}
```
### Client address
`ConnectionInfo`, passed to every controller and handler, carries the peer address. `peer_addr()` returns it as a `std::net::SocketAddr`; the raw `client.ip` / `client.port` fields are also public:
```rust
fn process(_request: &Request, response: Response, connection: &ConnectionInfo) -> Response {
if let Some(addr) = connection.peer_addr() {
println!("request from {}", addr); // e.g. "127.0.0.1:54321"
}
println!("ip={} port={}", connection.client.ip, connection.client.port);
response
}
```
This is the TCP peer. Behind a load balancer it is the balancer's address; `X-Forwarded-For` is not consulted.
## Response
`Response` is defined in `src/response/mod.rs`:
```rust
pub struct Response {
pub http_version: String, // "HTTP/1.1"
pub status_code: i16, // 200, 404, etc.
pub reason_phrase: String, // "OK", "Not Found", etc.
pub headers: Vec,
pub content_range_list: Vec, // response body parts
pub stream_file: Option, // path of a file streamed from disk
pub stream_pipe: Option>, // reader piped chunked; wins over both
}
```
### Building a response with get_response
The most concise way to build a response:
```rust
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::header::Header;
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
let body = Range::get_content_range(
b"{\"ok\":true}".to_vec(),
MimeType::APPLICATION_JSON.to_string(),
);
let response = Response::get_response(
STATUS_CODE_REASON_PHRASE.n200_ok,
Some(Header::get_header_list(request)), // standard response headers
Some(vec![body]),
);
```
`Response::get_response` signature:
```rust
pub fn get_response(
status_code_reason_phrase: &StatusCodeReasonPhrase,
boxed_header_list: Option>,
boxed_content_range_list: Option>,
) -> Response
```
Pass `None` for headers to get an empty header list. Pass `None` for the body list to get an empty body (useful for `204 No Content`). A `1xx`, `204` or `304` response is always sent without a body or `Content-Length`, whatever the handler put in it (`Response::drop_content_for_status`; since 17.113.2). A `HEAD` response keeps the `Content-Length` of the `GET` it stands for, without the body.
### One-line JSON / text responses
For the common case — a JSON or plain-text body with no extra headers — `Response::json`/`Response::text` skip `get_response`/`Range::get_content_range` entirely:
```rust
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
let response = Response::json(STATUS_CODE_REASON_PHRASE.n200_ok, b"{\"ok\":true}".to_vec());
let error_response = Response::text(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid input");
```
`Response::json` takes already-serialized bytes, so it has no dependency on the `serde` feature — build `bytes` with `serde_json::to_vec(&value)?` or hand-rolled JSON. See [JSON](/building-apps/json/) for the `serde`-backed `Json` extractor/responder, which serializes a typed value for you in one call.
### Redirects
Set a 3xx status and a `Location` header:
```rust
use rust_web_server::header::Header;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
fn process(_request: &Request, mut response: Response, _connection: &ConnectionInfo) -> Response {
response.status_code = *STATUS_CODE_REASON_PHRASE.n301_moved_permanently.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n301_moved_permanently.reason_phrase.to_string();
response.headers.push(Header::new(Header::_LOCATION, "https://example.com/new-path"));
response
}
```
`n302_found`, `n303_see_other`, `n307_temporary_redirect` and `n308_permanent_redirect` work the same way.
### Streaming large files
For large files, set `stream_file` instead of loading bytes into `content_range_list` (the static file server does this above 256 KiB, `StaticResourceController::STREAM_THRESHOLD`). Memory stays constant: the body goes out as-is when you set a `Content-Length` header, otherwise with `Transfer-Encoding: chunked`. To stream from any `Read` source, set `stream_pipe` instead:
```rust
response.stream_file = Some("/var/data/large-export.csv".to_string());
```
## STATUS_CODE_REASON_PHRASE constants
The `STATUS_CODE_REASON_PHRASE` constant provides typed access to every standard HTTP status code:
```rust
use rust_web_server::response::STATUS_CODE_REASON_PHRASE;
STATUS_CODE_REASON_PHRASE.n200_ok
STATUS_CODE_REASON_PHRASE.n201_created
STATUS_CODE_REASON_PHRASE.n204_no_content
STATUS_CODE_REASON_PHRASE.n400_bad_request
STATUS_CODE_REASON_PHRASE.n401_unauthorized
STATUS_CODE_REASON_PHRASE.n403_forbidden
STATUS_CODE_REASON_PHRASE.n404_not_found
STATUS_CODE_REASON_PHRASE.n409_conflict
STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity
STATUS_CODE_REASON_PHRASE.n429_too_many_requests
STATUS_CODE_REASON_PHRASE.n500_internal_server_error
// ... and all other 1xx–5xx codes
```
Each entry has `.status_code: &'static i16` and `.reason_phrase: &'static str`. Set them on a response:
```rust
response.status_code = *STATUS_CODE_REASON_PHRASE.n201_created.status_code;
response.reason_phrase = STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string();
```
## Header constants
`Header` in `src/header/mod.rs` exposes string constants for all standard HTTP header names:
```rust
use rust_web_server::header::Header;
Header::_CONTENT_TYPE // "Content-Type"
Header::_CONTENT_LENGTH // "Content-Length"
Header::_CONTENT_RANGE // "Content-Range"
Header::_AUTHORIZATION // "Authorization"
Header::_CACHE_CONTROL // "Cache-Control"
Header::_LOCATION // "Location"
Header::_SET_COOKIE // "Set-Cookie"
Header::_FORWARDED // "Forwarded"
// ... all standard request and response headers
```
Create a header value pair:
```rust
use rust_web_server::header::Header;
// name and value are Cow<'static, str> (18.0.0): a &'static str is borrowed,
// a String (e.g. format!(..)) is owned. Read them as &str via deref.
let location = Header::new(Header::_LOCATION, "/new-path");
response.headers.push(location);
```
### get_header_list
`Header::get_header_list(request)` returns the standard set of response headers (CORS, `Date`, cache control, security headers; client hints only with `RWS_CONFIG_CLIENT_HINTS=true`) pre-populated from the request context. Call it at the start of a controller's `process` method when building a response from scratch:
```rust
let mut header_list = Header::get_header_list(request);
header_list.push(Header::new(Header::_CACHE_CONTROL, "no-store"));
```
## MimeType constants
`MimeType` in `src/mime_type/mod.rs` provides string constants for common content types:
```rust
use rust_web_server::mime_type::MimeType;
MimeType::APPLICATION_JSON // "application/json"
MimeType::APPLICATION_OCTET_STREAM // "application/octet-stream"
MimeType::TEXT_PLAIN // "text/plain"
MimeType::TEXT_HTML // "text/html"
MimeType::TEXT_CSS // "text/css"
MimeType::TEXT_JAVASCRIPT // "text/javascript"
// ... and many more
```
Use `MimeType::detect_mime_type(filepath)` to derive the MIME type from a file extension at runtime:
```rust
let mime = MimeType::detect_mime_type("/assets/app.wasm"); // "application/wasm"
let mime = MimeType::detect_mime_type("/styles/main.css"); // "text/css"
let mime = MimeType::detect_mime_type("/data/feed.json"); // "application/json"
```
## Range::get_content_range
Wrap any `Vec<u8>` in a `ContentRange` for use in `response.content_range_list`:
```rust
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
let body = Range::get_content_range(
serde_json::to_vec(&payload).unwrap(),
MimeType::APPLICATION_JSON.to_string(),
);
response.content_range_list = vec![body];
```
For multipart range responses (e.g. `Accept-Ranges` support), push multiple `ContentRange` values into the list. The framework serialises them as `multipart/byteranges` automatically, with a `Content-Length` and a boundary that occurs in none of the parts (`Range::multipart_boundary`; since 17.113.2).
---
# Shared State
<https://rws8.tech/building-apps/state/>
## App::with_state
`App::with_state(S)` creates an `AppWithState` that stores your state behind an `Arc` and shares it across all handlers and threads. The bound is `S: Send + Sync + 'static`.
```rust
use rust_web_server::app::App;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
struct Config {
version: &'static str,
}
let app = App::with_state(Config { version: "1.0.0" })
.get("/version", |_req, _params, _conn, cfg| {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(
cfg.version.as_bytes().to_vec(),
MimeType::TEXT_PLAIN.to_string(),
)
];
r
});
```
## Handler signature
```rust
fn handler(
request: &Request,
params: &PathParams,
connection: &ConnectionInfo,
state: &S,
) -> Response
```
Handlers receive an immutable `&S` reference. The state type `S` itself is never cloned per-request — only the `Arc` wrapping it is cloned once at registration time.
Outside a handler — for example in a test — `app.state()` returns the same `&S`.
## routes! macro with state
The `routes!` macro works with any builder that has the `.get()` / `.post()` etc. methods, including `AppWithState`:
```rust
use rust_web_server::app::App;
use rust_web_server::routes;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
struct AppState {
db_url: String,
}
fn get_items(_req: &Request, _p: &PathParams, _c: &ConnectionInfo, s: &AppState) -> Response {
let _ = &s.db_url; // use db_url to open a connection
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
let app = routes! {
App::with_state(AppState { db_url: "postgres://localhost/mydb".to_string() }),
GET "/items" => get_items,
};
```
## Mutable shared data
Because handlers receive `&S` (not `&mut S`), you need interior mutability for data that changes after startup. Two common patterns:
### Arc> — exclusive write access
```rust
use std::sync::{Arc, Mutex};
use rust_web_server::app::App;
struct Counter {
hits: Arc>,
}
let app = App::with_state(Counter { hits: Arc::new(Mutex::new(0)) })
.get("/count", |_req, _params, _conn, state| {
let mut hits = state.hits.lock().unwrap();
*hits += 1;
// build response with *hits ...
todo!()
});
```
### Arc> — concurrent reads, exclusive writes
Prefer `RwLock` when reads vastly outnumber writes (e.g., a shared cache or config snapshot):
```rust
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use rust_web_server::app::App;
struct Cache {
store: Arc>>,
}
let app = App::with_state(Cache { store: Arc::new(RwLock::new(HashMap::new())) })
.get("/cache/:key", |_req, params, _conn, state| {
let key = params.get("key").unwrap_or("");
let guard = state.store.read().unwrap();
let value = guard.get(key).cloned().unwrap_or_default();
// build response with value ...
todo!()
});
```
## Accessing state from within state
If you store `Arc`-wrapped sub-components inside `S`, you can clone the inner `Arc` and spawn background tasks without coupling the lifetime to the request:
```rust
use std::sync::{Arc, Mutex};
struct JobQueue(Arc>>);
// Inside a handler:
// let queue = Arc::clone(&state.0);
// std::thread::spawn(move || { queue.lock().unwrap().push(job); });
```
## AsyncAppWithState — async handlers
When compiled with the `http2` feature, `App::with_async_state(S)` returns an `AsyncAppWithState` whose handlers are `async fn`:
```rust
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
struct Db { url: String }
let app = AsyncAppWithState::new(Db { url: "postgres://localhost/mydb".to_string() })
.get("/users/:id", |_req, params, _conn, state| async move {
let id = params.get("id").unwrap_or("unknown");
// await a real async DB call here
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
});
```
:::note[Feature requirement]
`AsyncAppWithState` requires `--features http2` (or the default `http3` feature). It is not available in the `http1`-only build because that build has no tokio runtime.
:::
The async bridge automatically detects whether it is running inside an existing tokio runtime (HTTP/2 / HTTP/3 path) or in the synchronous thread-pool (HTTP/1.1 path) and adapts accordingly.
## Unmatched requests
Routes are tried in registration order. Any request that does not match a registered route falls through to the built-in `App` controller chain (static files, `/healthz`, `/readyz`, `/metrics`, `404 Not Found`).
---
# Typed Extractors
<https://rws8.tech/building-apps/extractors/>
## FromRequest trait
`FromRequest` is defined in `src/extract/mod.rs`:
```rust
pub trait FromRequest: Sized {
fn from_request(request: &Request) -> Result;
}
```
Call `T::from_request(request)` to extract a typed value from the incoming request. On failure the implementation returns a ready-to-send `Response` (usually `400 Bad Request`) so handlers can early-return without duplicating error-building code.
## Built-in extractors
### Body — raw bytes
Clones the raw request body into a `Vec<u8>`. Never fails; an empty body produces an empty `Vec`.
```rust
use rust_web_server::extract::{Body, FromRequest};
fn handler(request: &Request) -> Response {
let Body(bytes) = Body::from_request(request).unwrap();
// bytes is Vec<u8>
todo!()
}
```
`Body` also exposes `.into_bytes(self) -> Vec<u8>` for consuming the wrapper.
### BodyText — UTF-8 decoded body
Returns `400 Bad Request` if the body bytes are not valid UTF-8.
```rust
use rust_web_server::extract::{BodyText, FromRequest};
use rust_web_server::error::{AppError, IntoResponse};
fn handler(request: &Request) -> Response {
let text = match BodyText::from_request(request) {
Ok(BodyText(s)) => s,
Err(err_response) => return err_response,
};
// text is String
todo!()
}
```
`BodyText` exposes `.as_str(&self) -> &str` for borrowing.
### Query — parsed query string
Parses the query string from `request.request_uri` into a `HashMap`. Never fails; a URI with no query string produces an empty map.
```rust
use rust_web_server::extract::{Query, FromRequest};
fn handler(request: &Request) -> Response {
let q = Query::from_request(request).unwrap();
let page = q.get("page").map(String::as_str).unwrap_or("1");
let limit = q.get("limit").map(String::as_str).unwrap_or("20");
// ...
todo!()
}
```
`Query` exposes `.get(&self, key: &str) -> Option<&String>`.
### RequestHeaders — all request headers
Clones the entire header list. Never fails.
```rust
use rust_web_server::extract::{RequestHeaders, FromRequest};
fn handler(request: &Request) -> Response {
let headers = RequestHeaders::from_request(request).unwrap();
let auth = headers.get("Authorization"); // case-insensitive lookup
// ...
todo!()
}
```
`RequestHeaders::get(&self, name: &str) -> Option<&str>` performs a case-insensitive lookup and returns the value of the first matching header.
### RequestId — correlation ID
The value of the `X-Request-Id` header (set by [`RequestIdLayer`](/building-apps/middleware/#built-in-middleware-layers)). Never fails; `.0` is an empty string when the header is absent.
```rust
use rust_web_server::extract::{RequestId, FromRequest};
let id = RequestId::from_request(request).unwrap();
let id: &str = id.as_str();
```
Two more extractors live outside `extract`: `json::Json` (`serde` feature, see [JSON](/building-apps/json/)) and `validate::Validated` (see [Validation](/building-apps/validation/)).
## Combining extractors
Extractors are ordinary function calls; combine as many as you need:
```rust
use rust_web_server::extract::{BodyText, Query, RequestHeaders, FromRequest};
fn create_item(request: &Request, _params: &PathParams, _conn: &ConnectionInfo, _state: &()) -> Response {
// Early-return on bad body
let BodyText(body) = match BodyText::from_request(request) {
Ok(b) => b,
Err(r) => return r,
};
let query = Query::from_request(request).unwrap();
let headers = RequestHeaders::from_request(request).unwrap();
let dry_run = query.get("dry_run").map(|v| v == "true").unwrap_or(false);
let trace_id = headers.get("X-Trace-Id").unwrap_or("none");
// process body, dry_run, trace_id ...
todo!()
}
```
## Writing a custom extractor
Implement `FromRequest` for any type that can be derived from a `Request`:
```rust
use rust_web_server::extract::FromRequest;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::header::Header;
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
/// Extracts a Bearer token from the Authorization header.
pub struct BearerToken(pub String);
impl FromRequest for BearerToken {
fn from_request(request: &Request) -> Result {
let auth = request
.get_header("Authorization".to_string())
.and_then(|h| h.value.strip_prefix("Bearer ").map(str::to_string));
match auth {
Some(token) => Ok(BearerToken(token)),
None => {
let header_list = Header::get_header_list(request);
let body = Range::get_content_range(
b"Missing or invalid Authorization header".to_vec(),
MimeType::TEXT_PLAIN.to_string(),
);
Err(Response::get_response(
STATUS_CODE_REASON_PHRASE.n401_unauthorized,
Some(header_list),
Some(vec![body]),
))
}
}
}
}
// Usage:
// let BearerToken(token) = BearerToken::from_request(request)?;
```
## `#[derive(FromRequest)]`
The `macros` feature can generate the boilerplate above for a named-field struct where every field is itself already an extractor — each field's `from_request` runs in declaration order, and the first failure short-circuits with that field's error response:
```toml
# Cargo.toml
rust-web-server = { version = "18", features = ["macros"] }
```
```rust
use rust_web_server::extract::{BodyText, Query};
#[derive(rust_web_server::FromRequest)]
struct Payload {
body: BodyText,
params: Query,
}
// let Payload { body, params } = Payload::from_request(request)?;
```
This covers structs built from existing extractors (`Body`, `BodyText`, `Query`, `RequestHeaders`, `RequestId`, or your own custom ones). A field like `BearerToken` above works too, as long as it implements `FromRequest` itself — the derive doesn't know how to pull an arbitrary field from a query parameter or header on its own; write a small custom extractor (as shown above) for that and include it as a field.
---
# Error Handling
<https://rws8.tech/building-apps/error-handling/>
## IntoResponse trait
`IntoResponse` is defined in `src/error/mod.rs`:
```rust
pub trait IntoResponse {
fn into_response(self) -> Response;
}
```
Implement this on your error enum so handlers can return `Result` and convert the error to an HTTP response with `.unwrap_or_else(|e| e.into_response())`.
`Response` itself implements `IntoResponse` as the identity conversion, so it can be used wherever `impl IntoResponse` is expected.
## AppError enum
`AppError` is a built-in typed error that covers the most common HTTP failure cases:
```rust
#[derive(Debug, PartialEq, Eq)]
pub enum AppError {
BadRequest(String), // 400
Unauthorized, // 401
Forbidden, // 403
NotFound(String), // 404
Conflict(String), // 409
UnprocessableEntity(String), // 422
TooManyRequests, // 429
PayloadTooLarge(String), // 413
Internal(String), // 500
}
```
Each variant's `into_response()` produces a plain-text body from the attached message, with the appropriate status code and a standard header set.
## Returning Result from handlers
Handlers registered on `AppWithState` must return `Response`, not `Result`. Use `.unwrap_or_else` to fold the error into the response at the call site:
```rust
use rust_web_server::error::{AppError, IntoResponse};
use rust_web_server::response::Response;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
fn find_user(id: u64) -> Result {
if id == 0 {
return Err(AppError::NotFound("user not found".to_string()));
}
// ... real lookup
Err(AppError::Internal("db connection failed".to_string()))
}
fn get_user(
_req: &Request,
params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let id: u64 = params
.get("id")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
find_user(id).unwrap_or_else(|e| e.into_response())
}
```
## Custom error type
Implement `IntoResponse` on your own error enum when `AppError` does not map cleanly to your domain:
```rust
use rust_web_server::error::IntoResponse;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::header::Header;
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
#[derive(Debug)]
pub enum ApiError {
Validation(String),
DatabaseError(String),
NotAuthenticated,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
ApiError::Validation(msg) => (STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity, msg.as_str()),
ApiError::DatabaseError(msg) => (STATUS_CODE_REASON_PHRASE.n500_internal_server_error, msg.as_str()),
ApiError::NotAuthenticated => (STATUS_CODE_REASON_PHRASE.n401_unauthorized, "Not authenticated"),
};
// Build a minimal fake request so Header::get_header_list has something to work from.
use rust_web_server::request::Request;
let dummy = Request {
method: "GET".to_string(),
request_uri: "/".to_string(),
http_version: "HTTP/1.1".to_string(),
headers: vec![],
body: Default::default(),
};
let headers = Header::get_header_list(&dummy);
let body = Range::get_content_range(
message.as_bytes().to_vec(),
MimeType::TEXT_PLAIN.to_string(),
);
Response::get_response(status, Some(headers), Some(vec![body]))
}
}
```
Then use it in handlers exactly like `AppError`:
```rust
fn handler(...) -> Response {
do_something().unwrap_or_else(|e: ApiError| e.into_response())
}
```
## AppError variant reference
| Variant | Status | Body |
|---|---|---|
| `BadRequest(msg)` | 400 | `msg` |
| `Unauthorized` | 401 | `"Unauthorized"` |
| `Forbidden` | 403 | `"Forbidden"` |
| `NotFound(msg)` | 404 | `msg` |
| `Conflict(msg)` | 409 | `msg` |
| `UnprocessableEntity(msg)` | 422 | `msg` |
| `TooManyRequests` | 429 | `"Too Many Requests"` |
| `PayloadTooLarge(msg)` | 413 | `msg` |
| `Internal(msg)` | 500 | `msg` |
:::note[Content-Type]
`AppError::into_response()` always sets `Content-Type: text/plain`. To return JSON error bodies, implement your own `IntoResponse` and pass `MimeType::APPLICATION_JSON` to `Range::get_content_range`.
:::
---
# Middleware
<https://rws8.tech/building-apps/middleware/>
## The Middleware trait
`Middleware` is defined in `src/middleware/mod.rs`:
```rust
pub trait Middleware: Send + Sync {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result;
}
```
Call `next.execute(request, connection)` to pass the request to the next layer (or the inner application). Return a `Response` directly to short-circuit the chain.
## Wrapping an application
`.wrap(layer)` attaches a middleware layer to any `Application`. Layers run in registration order — the first `.wrap()` call is the outermost layer (runs first on the way in, last on the way out):
```rust
use rust_web_server::app::App;
use rust_web_server::middleware::{RateLimitLayer, WithMiddleware};
use rust_web_server::core::New;
let app = App::new()
.wrap(RateLimitLayer); // outermost: checked before any route
```
Chain multiple layers:
```rust
use rust_web_server::metrics::MetricsLayer;
let app = App::new()
.wrap(RateLimitLayer) // checked first
.wrap(MetricsLayer); // checked second
```
`App`, `App::with_state(S)` and an already-wrapped `WithMiddleware` expose the same `.wrap()` method; for any other `Application` (e.g. `AsyncAppWithState`), start with `WithMiddleware::new(app).wrap(layer)`.
## Built-in middleware layers
| Layer | Module | Description |
|---|---|---|
| `RateLimitLayer` | `middleware` | Enforces the process-wide sliding-window rate limit; returns `429` when the budget for the client IP is exceeded. Configured via `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` / `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`. |
| `MetricsLayer` | `metrics` | Records per-route `rws_route_requests_total` counters and `rws_route_duration_seconds` histograms in Prometheus format. |
| `CacheLayer` | `cache` | In-process response cache; caches `GET` responses by URI. |
| `OtelLayer` | `otel` | Emits OpenTelemetry spans and propagates W3C `traceparent` context. |
| `RewriteLayer` | `rewrite` | Applies configurable request/response rewrite rules (header set/remove, URI prefix manipulation, response body find-and-replace). |
| `ReverseProxy` | `proxy` | HTTP/1.1 reverse proxy; forwards the request to a backend and returns its response. |
| `H2ReverseProxy` | `proxy` | HTTP/2 upstream proxy (requires `http2` feature). |
| `GrpcProxy` | `proxy` | Wraps `H2ReverseProxy`; only proxies requests with `Content-Type: application/grpc*`. |
| `BasicAuthLayer` | `auth` | HTTP Basic authentication; calls a user-supplied verifier closure. |
| `JwtLayer` | `auth` | JWT Bearer token verification; rejects requests without a valid token. |
| `IpFilter` | `ip_filter` | Allow-list or deny-list based on client IP address or CIDR range. |
| `BlocklistLayer` | `blocklist` | Runtime-updatable IP blocklist; unlike `IpFilter`, the list can be modified without restart. |
| `CanaryLayer` | `canary` | Routes a configured percentage of traffic to a canary backend. |
| `RetryLayer` | `circuit_breaker` | Re-dispatches a request whose response status is in the `retry_on` list, up to `max_retries` more times (no backoff). |
| `CsrfLayer` | `csrf` | CSRF token validation for state-mutating requests. |
| `LogLayer` | `request_log` | Structured per-request access logging (combined or JSON format). |
| `MaintenanceLayer` | `maintenance` | Returns `503 Service Unavailable` when maintenance mode is active. |
| `RequestIdLayer` | `request_id` | Reads or generates a request/correlation ID and echoes it on the request and response (`X-Request-Id` by default). |
| `ForwardAuthLayer` | `auth::forward` | Delegates the allow/deny decision for every request to an external HTTP auth service. |
### Quick usage examples
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::middleware::RateLimitLayer;
use rust_web_server::metrics::MetricsLayer;
use rust_web_server::ip_filter::IpFilter;
use rust_web_server::auth::JwtLayer;
use rust_web_server::request_log::LogLayer;
let app = App::new()
.wrap(LogLayer)
.wrap(IpFilter::deny(["1.2.3.4", "10.0.0.0/8"]))
.wrap(JwtLayer::new(b"secret".to_vec()))
.wrap(RateLimitLayer)
.wrap(MetricsLayer);
```
## Writing a custom middleware layer
Implement the `Middleware` trait on a unit struct (or a struct carrying configuration):
```rust
use rust_web_server::middleware::Middleware;
use rust_web_server::application::Application;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
/// Adds an X-Request-Id header to every response.
pub struct RequestIdLayer;
impl Middleware for RequestIdLayer {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result {
// Call into the rest of the chain / inner application
let mut response = next.execute(request, connection)?;
// Mutate the response on the way out
let id = format!("{}-{}", connection.server.ip, connection.server.port);
response.headers.push(rust_web_server::header::Header::new("X-Request-Id", id));
Ok(response)
}
}
```
Register it like any built-in layer:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
let app = App::new().wrap(RequestIdLayer);
```
### Running on the event loop (`runs_inline`)
The async servers normally hand each request to a blocking thread pool, so a
slow handler can't stall other connections. For requests that are quick and
never wait — health checks, metrics, small static files — that handoff costs
more than the work, so rws runs them directly on the event loop, as nginx
does. A request runs inline only if **every** layer and the inner application
say so for that request:
```rust
impl Middleware for RequestIdLayer {
fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application)
-> Result { /* … */ }
// Only in-memory work: safe on the event loop.
fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
true
}
}
```
The default is `false`, and it must stay `false` for a layer that can wait —
a database or Redis call, an outbound HTTP request, a lock held across I/O.
A layer that doesn't opt in simply keeps the stack on the blocking pool, so
leaving it out is always safe. The built-in in-memory layers
(`RateLimitLayer`, `CacheLayer`, `IpFilter`, `RequestIdLayer`, `MetricsLayer`,
`LogLayer`, `BlocklistLayer`, `MaintenanceLayer`) opt in; user route handlers,
proxy routes and MCP calls never run inline.
### Streamed request bodies (`accepts_streamed_body`)
An application can take a request's body as a stream
(`Application::streams_body`, see [Streaming large uploads](/building-apps/forms-uploads/#streaming-large-uploads)).
Through a `WithMiddleware` stack that happens only if every layer returns
`true` from `accepts_streamed_body` — the layer then sees an empty
`request.body`, so return `true` only from a layer that never reads it. The
built-in header-only layers do; the default is `false`.
A layer that answers a request itself — a proxy forwarding it, without
calling `next` — can claim the stream with `answers_streamed_body` (since
18.3.0): the body then streams whatever the layers after it and the
application would say, provided the layers before it accept it.
`H2ReverseProxy` and `GrpcProxy` do, for the requests they forward (gRPC
client streaming).
### Passing requests through (`is_passthrough`)
Some applications can answer a request asynchronously (`Application::execute_async`,
`http2` feature) — the config-driven proxy does, for plain-HTTP routes without
middleware, so it waits for the backend without holding a thread. A middleware
stack can only offer that path if none of its layers needs to run. A layer
that does nothing for a given request — a rate limit that isn't configured, a
filter that doesn't apply — says so with `is_passthrough`:
```rust
fn is_passthrough(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
!self.enabled
}
```
Default `false`: the request then takes the usual synchronous path through
every layer.
### Short-circuit example
Return a response without calling `next` to block the request:
```rust
use rust_web_server::middleware::Middleware;
use rust_web_server::application::Application;
use rust_web_server::error::{AppError, IntoResponse};
use rust_web_server::request::{METHOD, Request};
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
/// Rejects all non-GET/HEAD requests to /public/*.
pub struct ReadOnlyPublicLayer;
impl Middleware for ReadOnlyPublicLayer {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result {
let is_write = request.method != METHOD.get && request.method != METHOD.head;
let is_public = request.request_uri.starts_with("/public/");
if is_write && is_public {
return Ok(AppError::Forbidden.into_response());
}
next.execute(request, connection)
}
}
```
:::note[Layer ordering]
Layers push in order: `app.wrap(A).wrap(B)` runs A before B on the request path and B before A on the response path. Put logging and tracing outermost, authentication next, and business-logic concerns innermost.
:::
---
# Validation
<https://rws8.tech/building-apps/validation/>
Validation in `rust-web-server` is built around three pieces that compose cleanly:
- `Validate` — a trait your types implement to declare field-level rules
- `#[derive(Validate)]` — a proc-macro that generates the implementation from annotations
- `Validated` — a `FromRequest` wrapper that extracts and validates in one step
## The `Validate` trait
```rust
pub trait Validate {
fn validate(&self) -> Result<(), ValidationErrors>;
}
```
Implement it manually when you need custom logic:
```rust
use rust_web_server::validate::{Validate, ValidationErrors};
struct Payload {
name: String,
score: u32,
}
impl Validate for Payload {
fn validate(&self) -> Result<(), ValidationErrors> {
let mut errors = ValidationErrors::new();
if self.name.is_empty() {
errors.add("name", "must not be empty");
}
if self.score > 100 {
errors.add("score", "must be 100 or less");
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
}
```
The checks behind the derive's `email` and `url` validators are public, so a manual implementation can reuse them: `rust_web_server::validate::is_email(&self.email)` and `rust_web_server::validate::is_url(&self.homepage)`.
## `ValidationErrors`
`ValidationErrors` accumulates every failing field so the caller sees all problems at once.
| Method | Description |
|---|---|
| `ValidationErrors::new()` | Creates an empty collector |
| `.add(field, message)` | Records one failure |
| `.is_empty()` | Returns `true` when there are no errors |
| `.errors()` | Returns `&[FieldError]` — each has `.field` and `.message` |
| `.into_json()` | Serialises as `{"errors":[{"field":"…","message":"…"}]}` |
## `#[derive(Validate)]`
Add `features = ["macros"]` to your dependency to unlock the derive macro.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["macros"] }
```
Then annotate your struct fields with `#[validate(...)]` rules:
```rust
use rust_web_server::Validate;
#[derive(rust_web_server::Validate)]
struct SignupForm {
#[validate(length(min = 1, max = 50))]
username: String,
#[validate(email)]
email: String,
#[validate(length(min = 8, max = 128))]
password: String,
#[validate(range(min = 13, max = 120))]
age: u8,
#[validate(url)]
website: String,
#[validate(required)]
terms_accepted: String,
}
```
### Supported validators
| Syntax | What it checks |
|---|---|
| `length(min = N)` | `field.chars().count() >= N` |
| `length(max = N)` | `field.chars().count() <= N` |
| `length(min = N, max = N)` | both bounds |
| `range(min = N)` | `field as f64 >= N` |
| `range(max = N)` | `field as f64 <= N` |
| `range(min = N, max = N)` | both bounds |
| `email` | non-empty local part before the first `@`; domain after it at least 3 chars, contains `.`, and does not start or end with `.` |
| `required` | `!field.is_empty()` |
| `url` | starts with `http://` or `https://` |
All failures are collected before returning — the caller always sees every invalid field.
## `Validated` extractor
`Validated` implements `FromRequest` for any `T: FromRequest + Validate`. It first extracts `T` from the request, then runs validation:
- If extraction fails (e.g. invalid UTF-8) → `400 Bad Request`
- If validation fails → `422 Unprocessable Entity` with a JSON body
```rust
use rust_web_server::validate::{Validate, Validated, ValidationErrors};
use rust_web_server::extract::{FromRequest, BodyText};
use rust_web_server::request::Request;
use rust_web_server::response::Response;
fn handle(req: &Request) -> Response {
let Validated(form) = match Validated::::from_request(req) {
Ok(v) => v,
Err(r) => return r, // 400 or 422
};
// form.username, form.email, etc. are all valid here
Response::new()
}
```
The 422 body looks like:
```json
{
"errors": [
{ "field": "email", "message": "email must be a valid email address" },
{ "field": "username", "message": "username must be at least 1 character(s) long" }
]
}
```
## Complete example — signup endpoint
```rust
use rust_web_server::Validate;
use rust_web_server::validate::{Validate as _, Validated, ValidationErrors};
use rust_web_server::json::Json;
use rust_web_server::extract::FromRequest;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::routes;
use rust_web_server::app::App;
use serde::Deserialize;
#[derive(Deserialize, rust_web_server::Validate)]
struct SignupRequest {
#[validate(length(min = 1, max = 50))]
username: String,
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
impl FromRequest for SignupRequest {
fn from_request(req: &Request) -> Result {
let Json(payload) = Json::::from_request(req)?;
Ok(payload)
}
}
fn signup(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let Validated(body) = match Validated::::from_request(req) {
Ok(v) => v,
Err(r) => return r,
};
// At this point body.username, body.email, and body.password are valid.
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n201_created.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string();
r
}
let app = routes! {
App::with_state(()),
POST "/signup" => signup,
};
```
:::note[Manual vs. derived]
Prefer `#[derive(Validate)]` for straightforward field constraints. Implement `Validate` manually when you need cross-field rules (e.g. "password must equal password_confirm") or async lookups — just call the async code outside the `Validated` path.
:::
---
# Forms & File Uploads
<https://rws8.tech/building-apps/forms-uploads/>
`rust-web-server` provides two parsers for HTML form submissions, both living under `rust_web_server::body`:
- `FormUrlEncoded` — for `application/x-www-form-urlencoded` bodies (plain text fields)
- `FormMultipartData` — for `multipart/form-data` bodies (files and binary data)
## URL-encoded forms
```rust
use rust_web_server::body::form_urlencoded::FormUrlEncoded;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
fn handle_login(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let fields = match FormUrlEncoded::parse(req.body.to_vec()) {
Ok(map) => map,
Err(e) => {
// body was not valid UTF-8
let mut r = Response::new();
r.status_code = 400;
return r;
}
};
let username = fields.get("username").map(String::as_str).unwrap_or("");
let password = fields.get("password").map(String::as_str).unwrap_or("");
// ... authenticate ...
Response::new()
}
```
`FormUrlEncoded::parse` takes the raw body bytes (`Vec<u8>`) and returns `Result, String>`. Percent-encoding is decoded and ASCII control characters are stripped automatically.
To serialise a map back to URL-encoded format:
```rust
use std::collections::HashMap;
use rust_web_server::body::form_urlencoded::FormUrlEncoded;
let mut map = HashMap::new();
map.insert("q".to_string(), "hello world".to_string());
let encoded = FormUrlEncoded::generate(map);
// encoded == "q=hello+world" (or similar)
```
## Multipart form data
HTML file-upload forms use `enctype="multipart/form-data"`. The body consists of multiple **parts**, each with its own headers and a binary body. The parts are separated by a **boundary** string that appears in the `Content-Type` header.
### Extracting the boundary
```rust
use rust_web_server::body::multipart_form_data::FormMultipartData;
use rust_web_server::header::Header;
fn get_boundary(req: &Request) -> Result {
let ct = req.header("content-type")
.ok_or_else(|| "missing Content-Type".to_string())?;
FormMultipartData::extract_boundary(&ct.value)
}
```
`extract_boundary` splits the `Content-Type` value on `boundary=` and returns everything after it.
### Parsing the body
```rust
use rust_web_server::body::multipart_form_data::{FormMultipartData, Part};
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
fn upload(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
// 1. Get boundary from Content-Type
let content_type = req
.header("content-type")
.map(|h| h.value.to_string())
.unwrap_or_default();
let boundary = match FormMultipartData::extract_boundary(&content_type) {
Ok(b) => b,
Err(_) => {
let mut r = Response::new();
r.status_code = 400;
return r;
}
};
// 2. Parse all parts
let parts = match FormMultipartData::parse(&req.body, boundary) {
Ok(p) => p,
Err(_) => {
let mut r = Response::new();
r.status_code = 400;
return r;
}
};
// 3. Iterate over parts
for part in &parts {
// Each part has headers (e.g. Content-Disposition, Content-Type)
if let Some(disposition) = part.header("content-disposition") {
let name = extract_field_name(&disposition.value);
let file_name = extract_filename(&disposition.value);
if let Some(fname) = file_name {
// This part is a file upload
let file_bytes: &[u8] = &part.body;
println!("Received file '{}' ({} bytes)", fname, file_bytes.len());
// Save file_bytes to disk, S3, etc.
} else {
// Plain text field
let value = String::from_utf8_lossy(&part.body);
println!("Field '{}' = '{}'", name, value);
}
}
}
Response::new()
}
fn extract_field_name(disposition: &str) -> &str {
// Content-Disposition: form-data; name="field_name"; filename="file.txt"
disposition
.split(';')
.find_map(|seg| seg.trim().strip_prefix("name=\"").and_then(|s| s.strip_suffix('"')))
.unwrap_or("")
}
fn extract_filename(disposition: &str) -> Option<&str> {
disposition
.split(';')
.find_map(|seg| seg.trim().strip_prefix("filename=\"").and_then(|s| s.strip_suffix('"')))
}
```
### The `Part` type
Each `Part` returned by `FormMultipartData::parse` has:
| Field | Type | Description |
|---|---|---|
| `headers` | `Vec` | Per-part headers (Content-Disposition, Content-Type, etc.) |
| `body` | `Vec<u8>` | Raw bytes of this part's body |
Call `part.header("header-name")` (or `part.get_header(String)`) for case-insensitive lookup of a single header.
## Storing uploaded files
`FormMultipartData::parse` hands back raw bytes — it doesn't decide where they go. Use the [`Storage`](/features/storage/) trait (`storage-local` / `storage-s3` / `storage-azure` features) so the same handler code works against local disk in development and S3-compatible or Azure Blob object storage in production:
```rust
use rust_web_server::storage::{LocalStorage, Storage};
let store = LocalStorage::new("/var/data/uploads");
let key = store.put("avatars/42.png", &part.body, "image/png")?;
```
See [File / Object Storage](/features/storage/) for the full API, including `S3Storage` for AWS S3, Cloudflare R2, and MinIO, and `AzureBlobStorage` for Azure Blob Storage.
## Size limits
Two independent knobs apply to request bodies:
- **`RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES`** (default `10000`) — the size of each individual socket read. Bodies larger than this are accumulated across multiple reads automatically as long as the request declares `Content-Length`; this is a buffer-chunking size, not a hard cap on upload size.
- **`RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`** (default `0`, unlimited) — the actual maximum accepted body size. When set, a request whose declared `Content-Length` exceeds it is rejected with `413 Payload Too Large` **before** any of its body is read off the socket, and the connection is closed rather than kept alive.
For an upload endpoint, set `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` to the largest file you intend to accept; leave it at `0` if uploads are already bounded some other way (e.g. a reverse proxy in front of `rws`). On HTTP/2 and HTTP/3 the cap is checked as `DATA` frames arrive, so an oversized body gets `413` as soon as it crosses the limit.
The limit is global: routes are matched only after the whole request, body included, has been read, so no per-route limit can run before buffering. For one route that must be stricter, the config-driven proxy has [`[route.middleware] max_body_size`](/proxy/config-driven/), and a `Router`/`AppWithState` handler can check `request.body.len()` and return `AppError::PayloadTooLarge`. Both run after the body is in memory, so they protect that route's logic, not server memory.
## Streaming large uploads
By default a request's body is read into memory before the handler runs.
For large uploads a handler can instead read the body **as it arrives**
(since 18.0.0): opt in per route, and `request.body` is a stream —
memory stays at a few chunks whatever the size.
```rust
use std::io::Read;
use rust_web_server::app::App;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
let app = App::with_state(())
.put("/upload/:name", |request, params, _conn, _state| {
let path = format!("/var/data/uploads/{}", params.get("name").unwrap_or("upload"));
let mut file = match std::fs::File::create(&path) {
Ok(file) => file,
Err(e) => return Response::text(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, &e.to_string()),
};
// `&request.body` implements std::io::Read.
match std::io::copy(&mut &request.body, &mut file) {
Ok(bytes) => Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, &format!("{bytes} bytes")),
Err(e) => Response::text(STATUS_CODE_REASON_PHRASE.n400_bad_request, &e.to_string()),
}
})
.stream_body("PUT", "/upload/:name");
```
- Any `Application` can opt in by implementing
`streams_body(&self, request, connection) -> bool`; it is asked after the
head is read, so decide from the method, path and headers.
- The handler runs on a blocking-pool thread while the body arrives; a
client that stops, stalls past the read timeout, or sends more than
`RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` makes the read fail with an error,
rather than the body just ending early.
- If the handler answers without reading the whole body, the response goes
out with `Connection: close` and the connection is closed.
- Middleware must agree: a stack streams only if every layer's
`accepts_streamed_body` says so. The built-in header-only layers (access
log, rate limit, blocklist, IP filter, request id, metrics, maintenance,
cache, basic/JWT/forward auth) do; a layer that reads the body must not.
- Served over HTTP/1.1 (plain and TLS) and HTTP/2. On HTTP/2 the stream's
flow-control window is given back only as the handler reads, so a client
is never more than one window (64 KiB) ahead of it. HTTP/3 and the
HTTP/1.1-only build read the body first as before.
- To keep it in object storage instead of a file, pass the same reader to
[`Storage::put_stream`](/features/storage/#large-objects-put_stream) —
S3 then receives it in 8 MiB parts.
- `FormMultipartData::parse` still needs the whole body; for multipart
uploads of large files, read the body with `body_bytes()` only up to a
size you accept, or upload the raw file instead of a form.
## `Expect: 100-continue` for large uploads
If a client sends `Expect: 100-continue` with an upload (many HTTP clients — curl, most HTTP libraries — do this automatically for large request bodies), `rws` responds with the `100 Continue` interim status immediately after parsing the headers, before it reads any of the body. This is handled automatically on HTTP/1.1 — no application code needed:
```bash
curl -v -X PUT --data-binary @large-file.bin \
-H "Expect: 100-continue" http://localhost:7878/upload
# curl waits for "< HTTP/1.1 100 Continue" before it starts uploading
```
The `413`/`417` checks above run *before* the `100 Continue` is sent, not after:
- If the declared `Content-Length` exceeds `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`, the client gets `413 Payload Too Large` instead of `100 Continue` — it's never invited to upload a body that's already going to be rejected.
- An `Expect` value other than `100-continue` (the only one this server understands) gets `417 Expectation Failed`, also without reading any body.
This applies to HTTP/1.1 only (plain and TLS). HTTP/2 and HTTP/3 read request bodies as separate `DATA` frames after the headers, rather than one blocking read off a raw byte stream, so they don't have the head-of-line blocking risk `100-continue` exists to avoid.
## Generating multipart bodies (testing)
`FormMultipartData::generate` can build a multipart body for outbound requests or tests:
```rust
use rust_web_server::body::multipart_form_data::{FormMultipartData, Part};
use rust_web_server::header::Header;
let file_part = Part {
headers: vec![
Header::new("Content-Disposition", r#"form-data; name="avatar"; filename="photo.jpg""#),
Header::new("Content-Type", "image/jpeg"),
],
body: include_bytes!("photo.jpg").to_vec(),
};
let boundary = "--WebKitFormBoundaryABC123";
let body_bytes = FormMultipartData::generate(vec![file_part], boundary).unwrap();
```
---
# JSON
<https://rws8.tech/building-apps/json/>
`rust-web-server` ships two JSON layers:
1. **Built-in parser** (`rust_web_server::json::object::JSON`) — a zero-dependency, hand-rolled JSON parser that handles strings, numbers, booleans, null, nested objects, and arrays. No third-party crates required.
2. **`Json` extractor / responder** (`rust_web_server::json::Json`) — backed by `serde_json`; requires `features = ["serde"]`. Use this for typed request/response structs.
## `Json` extractor and responder (recommended)
Enable the `serde` feature in `Cargo.toml`:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
```
### Deserializing a request body
```rust
use serde::Deserialize;
use rust_web_server::json::Json;
use rust_web_server::extract::FromRequest;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
#[derive(Deserialize)]
struct CreatePost {
title: String,
body: String,
tags: Vec,
}
fn create_post(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let Json(payload) = match Json::::from_request(req) {
Ok(j) => j,
Err(r) => return r, // 400 Bad Request on parse failure
};
println!("New post: {}", payload.title);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n201_created.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string();
r
}
```
`Json::::from_request` calls `serde_json::from_slice` on `request.body` and returns a `400 Bad Request` response (with a human-readable error message as the body) on any parse failure.
`Json` also implements `FromRequest` directly, so it works with `#[derive(FromRequest)]`:
```rust
use rust_web_server::extract::FromRequest;
#[derive(rust_web_server::FromRequest)]
struct Payload {
body: Json,
}
```
### Serializing a response
```rust
use serde::Serialize;
use rust_web_server::json::Json;
use rust_web_server::response::Response;
#[derive(Serialize)]
struct PostResponse {
id: u64,
title: String,
slug: String,
}
fn get_post(
_req: &Request,
params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let post = PostResponse {
id: 42,
title: "Hello".to_string(),
slug: "hello".to_string(),
};
Json(post).into_response()
// Sets Content-Type: application/json and status 200 OK
}
```
`Json::into_response` uses `serde_json::to_vec`. If serialisation fails (extremely rare for well-formed `Serialize` implementations), it returns `500 Internal Server Error`.
### Dereferencing
`Json` implements `Deref` and `DerefMut` to `T`, so you can access fields without unwrapping:
```rust
let Json(payload) = Json::::from_request(req)?;
// or
let json = Json::::from_request(req)?;
println!("{}", json.title); // via Deref
```
## Setting `Content-Type: application/json` manually
`Response::json(status, bytes)` builds a `Content-Type: application/json` response in one call — no `serde` feature required, since it takes already-serialized bytes rather than a `Serialize` value:
```rust
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
let json_body = br#"{"status":"ok"}"#.to_vec();
let r = Response::json(STATUS_CODE_REASON_PHRASE.n200_ok, json_body);
```
`Response::text(status, &str)` is the `text/plain` equivalent:
```rust
let r = Response::text(STATUS_CODE_REASON_PHRASE.n400_bad_request, "invalid input");
```
Pair `Response::json` with `serde_json::to_vec(&value)?` if you have a `Serialize` value but want to build the response yourself. If you'd rather have serialization handled for you in one call, use `Json(value).into_response()` (`serde` feature, see above) instead.
Without either helper, build the `ContentRange` by hand through `Range::get_content_range`:
```rust
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
let json_body = br#"{"status":"ok"}"#.to_vec();
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(json_body, MimeType::APPLICATION_JSON.to_string())
];
```
## Built-in dynamic parser (`JSON` / `ToJSON` / `FromJSON`)
For cases where you cannot use serde or need to introspect JSON without a fixed schema, the built-in `JSON` struct in `rust_web_server::json::object` parses JSON into a property list.
```rust
use rust_web_server::json::object::JSON;
let raw = r#"{"name":"Alice","age":30,"active":true}"#.to_string();
let properties = JSON::parse_as_properties(raw).unwrap();
for (prop, value) in &properties {
match prop.property_type.as_str() {
"String" => println!("{} = {:?}", prop.property_name, value.string),
"i128" => println!("{} = {:?}", prop.property_name, value.i128),
"bool" => println!("{} = {:?}", prop.property_name, value.bool),
_ => {}
}
}
```
Each parsed entry is a `(JSONProperty, JSONValue)` pair.
`JSONProperty` has:
- `property_name: String`
- `property_type: String` — one of `"String"`, `"bool"`, `"i128"`, `"f64"`, `"object"`, `"array"`, `"null"`
`JSONValue` has optional fields for each type: `.string`, `.bool`, `.i128`, `.f64`, `.object`, `.array`, `.null`.
To serialise a property list back to a JSON string:
```rust
use rust_web_server::json::object::JSON;
let json_string = JSON::to_json_string(properties);
```
The `ToJSON` and `FromJSON` traits (also in `rust_web_server::json::object`) provide an interface for implementing dynamic JSON serialisation on your own types without serde.
## Error handling summary
| Scenario | Status | Trigger |
|---|---|---|
| Malformed JSON | `400 Bad Request` | `Json::::from_request` parse failure |
| Serialisation failure | `500 Internal Server Error` | `Json::into_response` `to_vec` error (rare) |
:::note[Choosing between the two approaches]
Use `Json` with `serde` for any production code where the JSON schema is known at compile time. Use the built-in `JSON` parser only when you need to handle arbitrary or schema-less JSON, or when you cannot add the `serde` dependency.
:::
---
# Cookies
<https://rws8.tech/building-apps/cookies/>
`rust-web-server` provides two types in `rust_web_server::cookie` for working with HTTP cookies:
- `CookieJar` — parses the `Cookie` request header into individual name/value pairs
- `SetCookie` — builder for `Set-Cookie` response header values
## Reading cookies from a request
The `Cookie` header value is a semicolon-separated list of `name=value` pairs. Pass the raw header value to `CookieJar::parse`:
```rust
use rust_web_server::cookie::CookieJar;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
fn dashboard(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let jar = req
.header("cookie")
.map(|h| CookieJar::parse(&h.value))
.unwrap_or_else(|| CookieJar::parse(""));
let session_id = jar.get("session").map(|c| c.value.as_str()).unwrap_or("");
if session_id.is_empty() {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
return r;
}
// session_id is available here
Response::new()
}
```
### `CookieJar` API
| Method | Signature | Description |
|---|---|---|
| `CookieJar::parse` | `fn parse(header_value: &str) -> CookieJar` | Parses a raw `Cookie` header value |
| `.get(name)` | `fn get(&self, name: &str) -> Option<&Cookie>` | Returns the first matching cookie |
The returned `Cookie` struct has two fields:
- `name: String`
- `value: String`
Names and values are trimmed of leading/trailing whitespace. If the `Cookie` header contains `session=abc123; theme=dark`, you get two cookies.
## Setting cookies in a response
Use `SetCookie` to build the value for the `Set-Cookie` response header. The builder is fluent — every method takes ownership and returns `Self`.
```rust
use rust_web_server::cookie::SetCookie;
use rust_web_server::header::Header;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::core::New;
fn login(
_req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let session_token = "eyJhbGc..."; // generate your token
let cookie_value = SetCookie::new("session", session_token)
.path("/")
.http_only()
.secure()
.same_site("Strict")
.max_age(3600) // 1 hour in seconds
.build();
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.headers.push(Header::new("Set-Cookie", cookie_value));
r
}
```
### `SetCookie` builder methods
| Method | RFC 6265 attribute | Description |
|---|---|---|
| `SetCookie::new(name, value)` | — | Creates the builder with name and value |
| `.path(path)` | `Path` | Restricts cookie to URL subtree |
| `.domain(domain)` | `Domain` | Restricts cookie to domain and sub-domains |
| `.max_age(seconds)` | `Max-Age` | Lifetime in seconds; `0` or negative deletes the cookie |
| `.secure()` | `Secure` | Only sent over HTTPS connections |
| `.http_only()` | `HttpOnly` | Inaccessible to JavaScript (`document.cookie`) |
| `.same_site(policy)` | `SameSite` | One of `"Strict"`, `"Lax"`, or `"None"` |
| `.build()` | — | Returns the formatted `Set-Cookie` header value string |
The built string looks like:
```
session=eyJhbGc...; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Strict
```
:::note[Expires attribute]
The `Expires` attribute (an absolute date) is not directly supported by `SetCookie`. Use `Max-Age` instead — it is preferred by RFC 6265 and avoids clock skew issues. If you need `Expires`, append it manually to the string returned by `.build()`.
:::
## Signed and encrypted cookie values
Plain `SetCookie` values are readable and forgeable by the client — fine for a UI preference like a theme, wrong for anything a handler trusts without re-checking server-side (a session identifier, a role, a discount eligibility flag). The `crypto` feature adds two function pairs in `rust_web_server::cookie` for exactly this:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["crypto"] }
```
| Function pair | Guarantee | Use when |
|---|---|---|
| `signed_cookie` / `verify_signed_cookie` | Tamper-evident, but still plain-text and readable by the client | The client only needs to *see* the value, not silently modify it |
| `encrypted_cookie` / `decrypt_cookie` | Confidential — unreadable and unmodifiable by the client | The value shouldn't be visible to the client at all |
### Signing (HMAC-SHA256)
```rust
use rust_web_server::cookie::{signed_cookie, verify_signed_cookie, SetCookie};
let secret = b"my-signing-secret"; // load from an env var in real code
// Issuing:
let cookie_value = signed_cookie("plan=pro", secret);
let header_value = SetCookie::new("prefs", &cookie_value).http_only().build();
// Reading it back later:
match verify_signed_cookie(&cookie_value, secret) {
Some(value) => { /* value == "plan=pro"; trust it */ }
None => { /* missing, malformed, or tampered — treat as absent */ }
}
```
`signed_cookie` returns `"<value>.<hex-signature>"`. `verify_signed_cookie` splits on the *last* `.`, so a value that itself contains dots still round-trips correctly — the fixed-length hex HMAC signature never contains one. Any tampering with either half, or verifying with the wrong secret, returns `None`.
### Encryption (AES-256-GCM)
```rust
use rust_web_server::cookie::{encrypted_cookie, decrypt_cookie, SetCookie};
let key = b"my-encryption-key"; // any length — SHA-256-derived into the 256-bit AES key
// Issuing:
let cookie_value = encrypted_cookie("session-token-abc123", key);
let header_value = SetCookie::new("sess", &cookie_value).http_only().secure().build();
// Reading it back later:
match decrypt_cookie(&cookie_value, key) {
Some(value) => { /* value == "session-token-abc123" */ }
None => { /* missing, malformed, tampered, or wrong key */ }
}
```
`encrypted_cookie` returns `"<hex-nonce>.<hex-ciphertext-and-tag>"`, generating a fresh random 96-bit nonce on every call — encrypting the same value twice never produces the same cookie value, so an observer can't correlate repeated issuances. `decrypt_cookie` returns `None` on any failure (GCM's authentication tag check fails closed rather than returning garbage on a tampered ciphertext).
:::note[Choosing between the two]
Both fail closed to `None` rather than exposing *why* verification failed — this avoids giving an attacker an oracle to probe. If you don't need confidentiality, prefer `signed_cookie`: it's cheaper and the value stays inspectable in a browser's dev tools during development.
:::
## Deleting a cookie
Set `Max-Age` to `0` to instruct the browser to delete the cookie immediately:
```rust
let cookie_value = SetCookie::new("session", "")
.path("/")
.http_only()
.max_age(0)
.build();
```
## Setting multiple cookies
Push one `Set-Cookie` header per cookie. HTTP allows (and requires) multiple `Set-Cookie` headers in a single response:
```rust
r.headers.push(Header::new("Set-Cookie", SetCookie::new("session", token).path("/").http_only().build()));
r.headers.push(Header::new("Set-Cookie", SetCookie::new("theme", "dark").path("/").max_age(31536000).build()));
```
## Full request/response cycle example
```rust
use rust_web_server::cookie::{CookieJar, SetCookie};
use rust_web_server::header::Header;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::core::New;
fn refresh_session(
req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
// Read the existing session cookie
let jar = req
.header("cookie")
.map(|h| CookieJar::parse(&h.value))
.unwrap_or_else(|| CookieJar::parse(""));
let old_token = jar.get("session").map(|c| c.value.as_str()).unwrap_or("");
if old_token.is_empty() {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
return r;
}
// Rotate the token
let new_token = rotate_token(old_token);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.headers.push(Header::new(
"Set-Cookie",
SetCookie::new("session", new_token)
.path("/")
.http_only()
.secure()
.same_site("Strict")
.max_age(3600)
.build(),
));
r
}
fn rotate_token(old: &str) -> String {
// your token rotation logic
format!("{}_rotated", old)
}
```
---
# Async Handlers
<https://rws8.tech/building-apps/async-handlers/>
`AsyncAppWithState` is the async counterpart to `AppWithState`. Its handlers are `async fn` closures that can `await` database queries, outbound HTTP calls, timers, or any other async I/O.
:::caution[Feature requirement]
`AsyncAppWithState` requires the `http2` feature (or the default `http3` feature). It is **not** available in `--no-default-features --features http1` builds.
```toml
[dependencies]
rust-web-server = { version = "18" } # default features include http2 + http3
```
:::
## Creating an `AsyncAppWithState`
Call `AsyncAppWithState::new(state)` with your shared state value, then chain `.get()`, `.post()`, etc. to register handlers.
```rust
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::router::PathParams;
use rust_web_server::request::Request;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::core::New;
struct AppState {
db_url: String,
}
let app = AsyncAppWithState::new(AppState { db_url: "postgres://…".to_string() })
.get("/greet/:name", |_req, params, _conn, state| async move {
let name = params.get("name").unwrap_or("world");
let body = format!("Hello, {}! DB: {}", name, state.db_url);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(body.into_bytes(), MimeType::TEXT_PLAIN.to_string())
];
r
});
```
## Handler signature
Async handlers take four owned parameters so the returned future is `'static`:
```rust
async fn handler(
req: Request, // owned — not a reference
params: PathParams,
conn: ConnectionInfo,
state: Arc,
) -> Response
```
The state is passed as `Arc` (a cheap clone), keeping all handlers behind shared ownership.
## Named handler functions
You can name your handlers instead of using inline closures. This is easier to test and keeps long business logic out of the router call-site:
```rust
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
struct Db {
url: String,
}
async fn get_user(
_req: Request,
params: PathParams,
_conn: ConnectionInfo,
state: Arc,
) -> Response {
let user_id = params.get("id").unwrap_or("0");
// Await an async database call here
// let user = sqlx::query!(...).fetch_one(&pool).await?;
let body = format!("{{\"id\":{}}}", user_id);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
let app = AsyncAppWithState::new(Db { url: "sqlite::memory:".to_string() })
.get("/users/:id", get_user);
```
## Supported HTTP methods
| Builder method | HTTP method |
|---|---|
| `.get(pattern, handler)` | GET |
| `.post(pattern, handler)` | POST |
| `.put(pattern, handler)` | PUT |
| `.patch(pattern, handler)` | PATCH |
| `.delete(pattern, handler)` | DELETE |
Path pattern syntax is identical to the synchronous router: `:name` matches a single segment, `*name` matches the rest of the path.
## How the runtime bridge works
`AsyncAppWithState` implements the synchronous `Application` trait so it fits into `Server::run_tls()` / `Server::run_quic()` alongside sync middleware. When `execute` is called:
- **Inside an existing tokio runtime** (HTTP/2 / HTTP/3 handlers): the async future runs in a scoped OS thread with its own single-threaded runtime to avoid blocking the event loop.
- **Outside any runtime** (HTTP/1.1 thread-pool): a temporary single-threaded tokio runtime is created, the future is driven to completion, and the runtime is dropped.
In both cases, the call site sees a synchronous `Result`. You do not need to think about this bridge when writing handlers.
## Custom `Application`s: event loop and async path
If you implement `Application` yourself, two optional methods control where the async servers run a request.
**`runs_inline`** — the servers hand every request to the blocking pool by default. A request that is quick and never waits can skip that handoff (the built-in `App` does this for static files and health checks):
```rust
use rust_web_server::application::Application;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::server::ConnectionInfo;
#[derive(Clone)]
struct Ping;
impl Application for Ping {
fn execute(&self, _request: &Request, _connection: &ConnectionInfo) -> Result {
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "pong"))
}
// In-memory, no I/O: safe to run on the event loop.
fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
true
}
}
```
Return `true` only for work that never waits on a database, the network or a lock held across I/O — on the event loop that would stall every connection on the same worker thread. Behind middleware, the request runs inline only if every layer opts in too ([`Middleware::runs_inline`](/building-apps/middleware/#running-on-the-event-loop-runs_inline)).
**`execute_async`** (`http2` feature) — `execute` is synchronous, so a blocking-pool thread sits idle while it waits on the network. An application with an async way to answer can offer it; the server awaits it on the connection's own task and falls back to `execute` when it returns `None`:
```rust
use rust_web_server::application::{Application, AsyncResponse};
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::server::ConnectionInfo;
#[derive(Clone)]
struct Slow;
impl Application for Slow {
fn execute(&self, _request: &Request, _connection: &ConnectionInfo) -> Result {
std::thread::sleep(std::time::Duration::from_millis(50)); // blocks a pool thread
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "done"))
}
fn execute_async<'a>(&'a self, _request: &'a Request, _connection: &'a ConnectionInfo) -> Option> {
Some(Box::pin(async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await; // holds no thread
Ok(Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "done"))
}))
}
}
```
Behind middleware, the async path is offered only if every layer passes the request through unchanged ([`Middleware::is_passthrough`](/building-apps/middleware/#passing-requests-through-is_passthrough)); otherwise `execute` runs as before. The config-driven proxy uses this for plain-HTTP routes without middleware. `AsyncAppWithState` itself does not use `execute_async` — its handlers go through the bridge above.
## Unmatched routes fall through
Routes that do not match any registered pattern are forwarded to the built-in `App` controller chain (static files, health checks, metrics, etc.). You only need to register handlers for your application routes.
## When to use async handlers
Use `AsyncAppWithState` when your handlers need to:
- Query an async database driver (e.g. `sqlx`, `tokio-postgres`)
- Make outbound HTTP calls via `reqwest` or `AsyncClient`
- Wait on async channels or timers
- Call any `async fn` from a third-party library
For handlers that only do synchronous work (reading headers, JSON parsing, string formatting), the synchronous `App::with_state` is sufficient and avoids the overhead of the runtime bridge.
## Full example — async HTTP call
```rust
use std::sync::Arc;
use rust_web_server::async_state::AsyncAppWithState;
use rust_web_server::http_client::AsyncClient;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
use rust_web_server::core::New;
struct Config {
upstream_url: String,
}
async fn proxy_data(
_req: Request,
_params: PathParams,
_conn: ConnectionInfo,
state: Arc,
) -> Response {
let result = AsyncClient::new()
.get(&state.upstream_url)
.send()
.await;
match result {
Ok(upstream_resp) => {
let body = upstream_resp.bytes().to_vec();
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![
Range::get_content_range(body, MimeType::APPLICATION_JSON.to_string())
];
r
}
Err(_) => {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
r
}
}
}
let app = AsyncAppWithState::new(Config {
upstream_url: "https://api.example.com/data".to_string(),
})
.get("/data", proxy_data);
```
---
# HTTP Client
<https://rws8.tech/building-apps/http-client/>
`rust_web_server::http_client` is a small outbound HTTP/1.1 client with no third-party HTTP dependency. It follows redirects (up to 10 by default; 301/302/303 become `GET`, 307/308 keep the method) and decodes chunked bodies.
| Build | What works |
|---|---|
| any build | `Client` over plain `http://` |
| `http-client` feature, or `http2`/`http3` (the default) | `Client` over `https://` (rustls + WebPKI roots) |
| `http2`/`http3` | `AsyncClient` (async `send()`) |
```toml
[dependencies]
# Only needed for HTTPS in an http1-only build; the default features already include it.
rust-web-server = { version = "18", default-features = false, features = ["http1", "http-client"] }
```
:::caution
In a build without `http-client`, `http2` or `http3`, an `https://` URL is not encrypted — the request cannot succeed. Enable one of those features before calling HTTPS endpoints.
:::
## Calling an API from a handler
```rust
use rust_web_server::http_client::{Client, HttpClientError};
fn fetch_user(id: u64) -> Result {
let client = Client::new();
let resp = client
.get(&format!("https://api.example.com/users/{id}"))
.header("Authorization", "Bearer tok_…")
.timeout_ms(5_000)
.send()?;
if resp.is_success() {
resp.text()
} else {
Err(HttpClientError(format!("upstream returned {}", resp.status())))
}
}
```
`Client::new()` uses a 30-second timeout and follows up to 10 redirects; change them with `Client::new().timeout_ms(ms)` and `.max_redirects(n)`, or per request with `RequestBuilder::timeout_ms`. `send()` blocks the calling thread — in the async servers a synchronous handler already runs on the blocking pool, so that is fine; inside an `async` handler use `AsyncClient` instead.
## Request bodies
```rust
use rust_web_server::http_client::Client;
// JSON — sets Content-Type: application/json
let resp = Client::new()
.post("https://api.example.com/charges")
.body_json(r#"{"amount":1000,"currency":"usd"}"#)
.header("Authorization", "Bearer sk_…")
.send()?;
assert!(resp.is_success());
// Form — percent-encodes each pair, sets Content-Type: application/x-www-form-urlencoded
let resp = Client::new()
.post("https://oauth2.googleapis.com/token")
.form(&[
("grant_type", "authorization_code"),
("code", "abc123"),
("client_id", "my-client-id"),
("client_secret", "my-client-secret"),
])
.send()?;
let json = resp.text()?;
```
`.body_text(s)` sends `text/plain`; `.body(bytes)` sends raw bytes with whatever `Content-Type` you set via `.header`. `.form()` is the body shape OAuth 2.0 token endpoints require — [SSO](/features/sso/) uses it for the code exchange.
## Reading the response
| Method | Returns |
|---|---|
| `.status()` | `u16` |
| `.is_success()` | `true` for 200–299 |
| `.is_redirect()` | `true` for 301/302/303/307/308 (returned once `max_redirects` is reached — `max_redirects(0)` disables following) |
| `.header(name)` | `Option<&str>`, case-insensitive |
| `.headers()` | `&[(String, String)]` |
| `.bytes()` | `&[u8]` |
| `.text()` | `Result` (UTF-8) |
| `.json::()` | `Result` — requires the `serde` feature |
`HttpClientError(pub String)` implements `std::error::Error` and `Display`.
## Async client
With the `http2` feature (included in the default build), `AsyncClient` has the same builder API — `get`/`post`/`put`/`patch`/`delete`/`request`, `.header`, `.body*`, `.form`, `.timeout_ms` — with an `async fn send()`:
```rust
use rust_web_server::http_client::{AsyncClient, HttpClientError};
async fn fetch_async(url: &str) -> Result {
AsyncClient::new().get(url).send().await?.text()
}
```
See [Async Handlers](/building-apps/async-handlers/#full-example--async-http-call) for a complete handler that forwards an upstream response.
---
# HTML Templates
<https://rws8.tech/building-apps/templates/>
`rust-web-server` integrates the [Tera](https://keats.github.io/tera/) template engine — a Jinja2-compatible HTML templating system with variables, control flow, filters, and inheritance.
:::caution[Feature requirement]
The template integration requires the `tera` feature:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["tera"] }
```
:::
## Quick start
1. Create a `templates/` directory next to your binary.
2. Call `template::init("templates")` once at startup.
3. Call `template::render("page.html", &ctx)` from any handler.
```rust
use rust_web_server::template::{self, Context};
fn main() {
// Initialize the global template engine once at startup.
template::init("templates").expect("failed to load templates");
// Start the server ...
}
```
## Initialisation
There are two ways to initialise the global engine:
### `template::init(dir)`
Loads all files under `dir` recursively (equivalent to the glob `dir/**/*`):
```rust
template::init("templates").unwrap();
```
### `template::init_from_env()`
Reads the directory from the `RWS_CONFIG_TEMPLATE_DIR` environment variable (default: `"templates"`):
```rust
template::init_from_env().unwrap();
```
Both functions return `Err` if called a second time (the engine is a `OnceLock`).
## Rendering in a handler
```rust
use rust_web_server::template::{self, Context};
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
fn home(
_req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
_state: &(),
) -> Response {
let mut ctx = Context::new();
ctx.insert("title", "Welcome");
ctx.insert("items", &["Rust", "rws", "Tera"]);
ctx.insert("logged_in", &true);
template::render("index.html", &ctx).unwrap_or_else(|e| {
// render() returns Err only when the template file is missing
// or contains a syntax error — treat as 500
let mut r = Response::new();
r.status_code = 500;
r
})
}
```
`template::render` delegates to the global `TeraEngine`, renders the named template, and returns a `200 OK` response with `Content-Type: text/html`.
## The `Context` type
`Context` is a re-export of `tera::Context`. Call `.insert(key, value)` with any `Serialize` value:
```rust
use rust_web_server::template::Context;
use serde::Serialize;
#[derive(Serialize)]
struct User {
name: String,
email: String,
}
let user = User { name: "Alice".into(), email: "alice@example.com".into() };
let mut ctx = Context::new();
ctx.insert("user", &user);
ctx.insert("count", &42_u32);
ctx.insert("flags", &["new", "featured"]);
```
## Directory structure convention
```
your-project/
├── src/
│ └── main.rs
└── templates/
├── base.html <- base layout
├── index.html
├── users/
│ ├── list.html
│ └── detail.html
└── partials/
└── nav.html
```
Template names passed to `render` are relative paths within the templates directory (e.g. `"users/list.html"`).
## Tera template syntax
### Variables
```html
<h1>{{ title }}</h1>
<p>Hello, {{ user.name }}!</p>
<p>Total: {{ count }}</p>
```
### Conditionals
```html
{% if logged_in %}
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>
{% endif %}
```
### Loops
```html
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
```
Loop provides a `loop` variable with helpers: `loop.index` (1-based), `loop.index0` (0-based), `loop.first`, `loop.last`.
### Template inheritance
Define a base layout:
```html
{# templates/base.html #}
<!DOCTYPE html>
<html>
<head><title>{% block title %}My Site{% endblock %}</title></head>
<body>
<nav>{% include "partials/nav.html" %}</nav>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
```
Extend it in a child template:
```html
{# templates/index.html #}
{% extends "base.html" %}
{% block title %}Home — My Site{% endblock %}
{% block content %}
<h1>{{ heading }}</h1>
<p>{{ body }}</p>
{% endblock %}
```
### Built-in filters
Tera ships many filters you can apply with `|`:
```html
{{ name | upper }} {# ALICE #}
{{ title | truncate(length=20) }}
{{ items | length }}
{{ price | round(precision=2) }}
{{ html_content | safe }} {# disable auto-escaping #}
{{ date | date(format="%Y-%m-%d") }}
```
Auto-escaping is enabled by default for `{{ }}` output — HTML special characters (`<`, `>`, `&`, `"`) are escaped. Use `| safe` only for trusted content.
### Macros
Define reusable template fragments:
```html
{% macro input(name, label, type="text") %}
<label for="{{ name }}">{{ label }}</label>
<input type="{{ type }}" id="{{ name }}" name="{{ name }}">
{% endmacro %}
{{ self::input(name="email", label="Email", type="email") }}
```
## `TeraEngine` directly
The global singleton wraps `TeraEngine`. You can also create a standalone engine when you need multiple template directories, or for testing:
```rust
use rust_web_server::template::{TeraEngine, Context};
// From a directory
let engine = TeraEngine::from_dir("templates").unwrap();
// From in-memory strings (useful in tests)
let engine = TeraEngine::from_raw(&[
("hello.html", "<p>Hello, {{ name }}!</p>"),
]).unwrap();
let mut ctx = Context::new();
ctx.insert("name", "World");
let html: String = engine.render("hello.html", &ctx).unwrap();
let response = engine.response("hello.html", &ctx).unwrap();
```
`TeraEngine::from_glob(pattern)` accepts any glob pattern Tera accepts, e.g. `"templates/**/*.html"`.
## Hot reload
Edit a template file and reload it without restarting the server:
```rust
use rust_web_server::template;
template::init("templates").unwrap();
// ... edit templates/index.html on disk ...
template::reload().unwrap();
```
`template::reload()`:
- **Re-globs the whole directory** — it picks up edited content in existing files *and* newly added or removed files matching the original pattern, not just a diff of what changed.
- **Is atomic.** It builds the full replacement set of templates before swapping it in. If one edited file has a syntax error (or an `{% extends %}`/`{% import %}` chain no longer resolves), `reload()` returns `Err` and the previously-loaded templates keep serving — a bad edit can't leave the live server with a half-broken template set.
- **Is wired into `SIGHUP` automatically.** [`config_reload::reload()`](/features/hot-reload/) calls it for you (as a no-op if `template::init()` was never called), so `kill -HUP $(pidof rws)` picks up edited templates alongside CORS/rate-limit/TLS-cert changes. Call `template::reload()` directly only if you want a different trigger — a file-watcher thread, or your own admin endpoint.
The equivalent method exists on a standalone `TeraEngine` too — `engine.reload()` — for the multi-directory case described above. It requires the engine to have been built from a glob (`from_dir`/`from_glob`/`init`); an engine built with `TeraEngine::from_raw` has no directory to re-read and `reload()` returns `Err` for it.
```rust
use rust_web_server::template::TeraEngine;
let mut engine = TeraEngine::from_dir("templates").unwrap();
// ... edit a template file ...
engine.reload().unwrap();
```
---
# Dependency Injection
<https://rws8.tech/building-apps/dependency-injection/>
`Container` is a type-keyed service store backed by `HashMap>`. Register services at startup, then pass the container directly as `AppWithState`/`AsyncAppWithState`'s state — it's `Send + Sync + 'static` like any other state type, so it needs no wrapping.
## Registering concrete services
`register::` wraps the value in `Arc` and stores it under `TypeId::of::()`. A second registration for the same type replaces the first.
```rust
use rust_web_server::di::Container;
struct EmailService {
host: String,
}
let mut container = Container::new();
container.register(EmailService { host: "smtp.example.com".into() });
let svc = container.get::().unwrap();
assert_eq!(svc.host, "smtp.example.com");
```
## Registering trait objects
Use `provide::<dyn Trait>(Arc::new(impl))` when the concrete type must be erased. The key is `TypeId::of::<dyn Trait>()`, so later resolution via `get::<dyn Trait>()` works correctly.
```rust
use std::sync::Arc;
use rust_web_server::di::Container;
pub trait UserRepo: Send + Sync {
fn find(&self, id: i64) -> Option;
}
struct PgUserRepo;
impl UserRepo for PgUserRepo {
fn find(&self, _id: i64) -> Option {
Some("Alice".into())
}
}
let mut container = Container::new();
container.provide::<dyn UserRepo>(Arc::new(PgUserRepo));
let repo = container.get::<dyn UserRepo>().unwrap();
assert_eq!(repo.find(1), Some("Alice".into()));
```
## Resolving services
Both concrete and trait-object registrations are resolved with the same method:
```rust
// concrete type
let svc: Option> = container.get::();
// trait object
let repo: Option> = container.get::<dyn UserRepo>();
```
`get` returns `None` when no matching registration exists — no panics.
## Named services
Register multiple instances of the same type under distinct string names with `register_named`. This is useful for primary/replica database pools or multiple external services.
```rust
use rust_web_server::di::Container;
let mut container = Container::new();
container
.register_named("primary", 5432u16)
.register_named("replica", 5433u16);
assert_eq!(*container.get_named::<u16>("primary").unwrap(), 5432);
assert_eq!(*container.get_named::<u16>("replica").unwrap(), 5433);
```
`provide_named::<dyn Trait>("name", arc)` is the trait-object equivalent.
## `into_arc()` — sharing outside `App::with_state`
`into_arc()` wraps the container in `Arc`, for call sites that need to share one container across multiple hand-built `Application`s.
```rust
let arc = container.into_arc(); // Arc```
Once wrapped you cannot add more registrations — perform all registrations first.
:::caution[Don't use `into_arc()` with `App::with_state`]
`App::with_state`/`App::with_async_state` already wrap their state argument in an `Arc` internally. Passing `container.into_arc()` there double-wraps it (`Arc>`) for no benefit, and handlers end up with `&Arc` instead of the simpler `&Container`. Pass the container itself — see below.
:::
## Wiring into request handlers
Pass the container directly as application state using `App::with_state`. Each handler receives `state: &Container` and can call `state.get::()` to resolve dependencies.
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::di::Container;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::routes;
fn get_version(
_req: &Request,
_params: &PathParams,
_conn: &ConnectionInfo,
state: &Container,
) -> Response {
let svc = state.get::().unwrap();
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
let mut container = Container::new();
container.register(EmailService { host: "smtp.example.com".into() });
let app = routes! {
App::with_state(container),
GET "/version" => get_version,
};
```
The same pattern works with `App::with_async_state` (requires the `http2` feature) for `async fn` handlers — pass the container directly there too.
## Complete example: UserRepository + DI wiring
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::di::Container;
use rust_web_server::request::Request;
use rust_web_server::router::PathParams;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::routes;
// --- domain trait ---
pub trait UserRepository: Send + Sync {
fn find_by_id(&self, id: i64) -> Option;
fn count(&self) -> usize;
}
// --- in-memory implementation (test / dev) ---
pub struct InMemoryUserRepo {
users: Vec<(i64, String)>,
}
impl UserRepository for InMemoryUserRepo {
fn find_by_id(&self, id: i64) -> Option {
self.users.iter().find(|(i, _)| *i == id).map(|(_, n)| n.clone())
}
fn count(&self) -> usize {
self.users.len()
}
}
// --- handler ---
fn get_user(
req: &Request,
params: &PathParams,
_conn: &ConnectionInfo,
state: &Container,
) -> Response {
let repo = state.get::<dyn UserRepository>().unwrap();
let id: i64 = params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
let mut r = Response::new();
match repo.find_by_id(id) {
Some(name) => {
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
}
None => {
r.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
}
}
r
}
// --- wiring ---
let repo = InMemoryUserRepo {
users: vec![(1, "Alice".into()), (2, "Bob".into())],
};
let mut container = Container::new();
container.provide::<dyn UserRepository>(Arc::new(repo));
let app = routes! {
App::with_state(container),
GET "/users/:id" => get_user,
};
```
:::note[Thread safety]
Every type registered with `register` or `provide` must implement `Send + Sync + 'static`. `Arc` satisfies those bounds whenever `T: Send + Sync`, making it the natural wrapper for shared services.
:::
## Inspection helpers
```rust
container.contains::(); // unnamed registration exists?
container.contains_named::<u16>("primary"); // named registration exists?
container.len(); // total unnamed registrations
container.is_empty(); // no unnamed registrations?
```
---
# CORS & Security Headers
<https://rws8.tech/features/cors-security/>
Every response includes a baseline set of security headers automatically. CORS is controlled by a set of `RWS_CONFIG_CORS_*` environment variables. Client Hints are opt-in.
## CORS
### Allow-all mode (opt-in)
With no CORS configuration, `rws` adds no CORS headers: browsers keep other sites from reading its responses (since 18.0.0 — allow-all used to be the default). When `RWS_CONFIG_CORS_ALLOW_ALL=true`, the server echoes back whatever `Origin` the browser sends and sets `Access-Control-Allow-Credentials: true`. On `OPTIONS` preflight requests it also mirrors back the `Access-Control-Request-Method` and `Access-Control-Request-Headers` values and adds `Access-Control-Max-Age: 86400`.
```bash
RWS_CONFIG_CORS_ALLOW_ALL=true
```
### Explicit origins mode
List the allowed origins as a comma-separated string (`RWS_CONFIG_CORS_ALLOW_ALL` stays `false`, the default):
```bash
RWS_CONFIG_CORS_ALLOW_ALL=false
RWS_CONFIG_CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com
RWS_CONFIG_CORS_ALLOW_CREDENTIALS=true
RWS_CONFIG_CORS_ALLOW_METHODS=GET,POST,PUT,DELETE,OPTIONS
RWS_CONFIG_CORS_ALLOW_HEADERS=Content-Type,Authorization,X-Request-ID
RWS_CONFIG_CORS_EXPOSE_HEADERS=X-Total-Count,X-Request-ID
RWS_CONFIG_CORS_MAX_AGE=3600
```
When the incoming `Origin` is not one of the listed origins no CORS headers are added to the response. Origins are compared whole (ASCII case ignored): with `https://app.example.com` listed, `https://app.example.co` and `https://app.example.com.evil` don't match (`Cors::origin_is_allowed`; before 17.113.2 a substring of the list matched). `Access-Control-Expose-Headers` is sent on the actual response, where the browser reads it, not only on the preflight.
### Environment variable reference
| Variable | Default | Description |
|---|---|---|
| `RWS_CONFIG_CORS_ALLOW_ALL` | `false` | Echo any origin back, with credentials (was `true` before 18.0.0) |
| `RWS_CONFIG_CORS_ALLOW_ORIGINS` | `""` | Comma-separated allowlist (used when allow-all is `false`) |
| `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` | `""` | Set `Access-Control-Allow-Credentials: true` |
| `RWS_CONFIG_CORS_ALLOW_METHODS` | `""` | Value for `Access-Control-Allow-Methods` on preflight |
| `RWS_CONFIG_CORS_ALLOW_HEADERS` | `""` | Value for `Access-Control-Allow-Headers` on preflight |
| `RWS_CONFIG_CORS_EXPOSE_HEADERS` | `""` | Value for `Access-Control-Expose-Headers`, on every response to an allowed origin |
| `RWS_CONFIG_CORS_MAX_AGE` | `86400` | Preflight cache TTL in seconds |
### Programmatic CORS (`Cors` struct)
For handlers that need fine-grained control, use `Cors::_process` directly:
```rust
use rust_web_server::cors::Cors;
let cors = Cors {
allow_all: false,
allow_origins: vec!["https://app.example.com".into()],
allow_methods: vec!["GET".into(), "POST".into()],
allow_headers: vec!["Authorization".into()],
allow_credentials: true,
expose_headers: vec![],
max_age: "3600".into(),
};
let headers = Cors::_process(&request, &cors)?;
```
### Hot reload
CORS configuration is re-read on `SIGHUP` (or `POST /admin/config/reload`) without a restart.
## Security headers
Every response carries `X-Content-Type-Options: nosniff`. On TLS connections
(HTTPS and HTTP/3) the server also sends:
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
The other browser-policy headers are opt-in (since 18.0.0 — before that they
were always sent): set `RWS_CONFIG_SECURITY_HEADERS=true`
(`security_headers = true` in `rws.config.toml`) and every response also
carries
| Header | Value |
|---|---|
| `X-Frame-Options` | `SAMEORIGIN` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` |
| `Content-Security-Policy` | `default-src 'self'` (unless `RWS_CONFIG_CSP` is set) |
They are off by default because most deployments set them per site (or in a
proxy in front), and each one is work on every response — over HTTP/2 every
header is HPACK-encoded.
### Content-Security-Policy
Set a policy with `RWS_CONFIG_CSP`; it is sent whether or not
`RWS_CONFIG_SECURITY_HEADERS` is on:
```bash
RWS_CONFIG_CSP="default-src 'self'; script-src 'self' cdn.example.com; img-src *"
```
With `RWS_CONFIG_SECURITY_HEADERS=true`, an empty value suppresses the
default policy:
```bash
RWS_CONFIG_CSP=
```
### Nanosecond timestamps
`RWS_CONFIG_NANOS_HEADERS=true` (`nanos_headers = true`) adds
`Date-Unix-Epoch-Nanos` (the response time in nanoseconds since the Unix
epoch, per response) and, for static files, `Last-Modified-Unix-Epoch-Nanos`.
Off by default since 18.0.0: `Date` and `Last-Modified` carry the same
information to the second, and values that change on every response can't be
indexed by HTTP/2's header compression.
## Client Hints
Client hints are opt-in (since 18.0.0): set `RWS_CONFIG_CLIENT_HINTS=true`
(`client_hints = true` in `rws.config.toml`) and every response advertises
the following hints via `Accept-CH` and `Critical-CH`:
```
Accept-CH: Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Full-Version-List,
Sec-CH-UA-Model, Sec-CH-UA-Platform-Version, Downlink, ECT, RTT,
Save-Data, Device-Memory, Sec-CH-Prefers-Reduced-Motion,
Sec-CH-Prefers-Color-Scheme
```
The hints also appear in the `Vary` header so caches key responses on them.
They're off by default because they add about 700 bytes to every response,
and `Critical-CH` makes Chromium retry a request that arrived without them.
A browser that already sends a hint (`Sec-CH-UA-*` low-entropy hints,
`Save-Data`) still sends it; reading an incoming hint in a handler works
either way:
```rust
use rust_web_server::client_hint::ClientHint;
use rust_web_server::request::Request;
fn handler(req: &Request) {
if let Some(h) = req.get_header(ClientHint::PREFERS_COLOR_SCHEME.to_string()) {
let scheme = &h.value; // "light" or "dark"
}
if let Some(h) = req.get_header(ClientHint::NETWORK_SAVE_DATA.to_string()) {
// serve lower-resolution images when Save-Data: on
}
}
```
Available hint constants in `ClientHint`:
| Constant | Header |
|---|---|
| `USER_AGENT_CPU_ARCHITECTURE` | `Sec-CH-UA-Arch` |
| `USER_AGENT_CPU_BITNESS` | `Sec-CH-UA-Bitness` |
| `USER_AGENT_FULL_BRAND_INFORMATION` | `Sec-CH-UA-Full-Version-List` |
| `USER_AGENT_DEVICE_MODEL` | `Sec-CH-UA-Model` |
| `USER_AGENT_OPERATING_SYSTEM_VERSION` | `Sec-CH-UA-Platform-Version` |
| `NETWORK_DOWNLOAD_SPEED` | `Downlink` |
| `NETWORK_EFFECTIVE_CONNECTION_TYPE` | `ECT` |
| `NETWORK_ROUND_TRIP_TIME` | `RTT` |
| `NETWORK_SAVE_DATA` | `Save-Data` |
| `DEVICE_MEMORY` | `Device-Memory` |
| `PREFERS_REDUCED_MOTION` | `Sec-CH-Prefers-Reduced-Motion` |
| `PREFERS_COLOR_SCHEME` | `Sec-CH-Prefers-Color-Scheme` |
:::note[Vary header]
With client hints on, the `Vary` response header includes the full list of hints so HTTP caches serve the correct variant per client capability; otherwise it is just `Vary: Origin`.
:::
---
# Static Files, Directory Listing & SPA Fallback
<https://rws8.tech/features/static-files/>
## Serving files
Any file under the process's working directory is served automatically — no configuration, no routes to register. `StaticResourceController` handles range requests, sets `ETag` / `Last-Modified` headers, negotiates gzip, and streams files larger than 256 KiB without loading them fully into memory.
### Browser caching
Static files are sent with `Cache-Control: no-cache` (since 18.0.0; it was
`no-store`): the browser keeps the file and asks again on the next use with
`If-None-Match` (the `ETag`) or `If-Modified-Since` (the `Last-Modified`
date), and gets a `304 Not Modified` without a body while the file is
unchanged. `If-None-Match` decides when both are sent (RFC 9110 §13.1.3).
`If-Modified-Since` takes any of the three HTTP-date forms: the IMF-fixdate
rws sends, RFC 850 (`Sunday, 06-Nov-94 08:49:37 GMT`) and asctime
(`Sun Nov 6 08:49:37 1994`).
Set `RWS_CONFIG_STATIC_CACHE_CONTROL` to change it — e.g.
`public, max-age=31536000, immutable` for files with a content hash in their
name — or to an empty value for the old `no-store` behavior. Responses from
your handlers keep `no-store, …` unless they set their own `Cache-Control`.
Files larger than 256 KiB are streamed from disk with an exact
`Content-Length` instead of being read into memory, so serving a 1 GB file
uses a few MiB. The static root is the working directory `rws` was started
in.
The request path is percent-decoded once before the lookup, so
`/my%20file.txt` serves `my file.txt` and `%2F` is a `/` (17.113.2); `+` is a
literal `+`. A path that isn't UTF-8 once decoded gets `404`.
Only files inside the root are served — checked on the decoded path, so
`/%2e%2e/` is refused like `/../`: a path with a `..` segment is refused
(`404`), and so is a symlink in the root that points outside it. Files and
directories whose name starts with `.` (`.git/`, `.env`) are not served unless
`RWS_CONFIG_SERVE_DOTFILES=true`; `.well-known/` always is (17.113.1).
Private-key and config files — `*.pem`, `*.key`, `rws.config.toml` — are
never served.
Range requests follow RFC 9110 over HTTP/1.1, HTTP/2, and HTTP/3: `bytes=2-5`,
open-ended `bytes=7-`, suffix `bytes=-500`, and multiple ranges get
`206 Partial Content` with an exact `Content-Range` (`bytes 7-9/10`); a range
ending past the file is clamped to its last byte; a range starting past the
end can't be satisfied: it is skipped, and only a request none of whose ranges can be satisfied gets `416`, with `Content-Range: bytes */<length>` (17.113.2). Full `200` responses carry no `Content-Range`. Overlapping
ranges are merged (and then sorted); more than 16 ranges are answered with
the whole file as one range, so a response never holds more than the file
(17.113.1).
Conditional requests: every file response carries an `ETag`. `If-None-Match`
takes a list of tags or `*` and compares weakly (`W/"x"` matches `"x"`), as
RFC 9110 §13.1.2 requires; a match is a `304 Not Modified` (17.113.2).
A `Range` with `If-Range` is honored only while the `If-Range` tag strongly
matches the current `ETag`, or is a date equal to the file's `Last-Modified`
(at least a second old); otherwise (another tag, a weak one, another date)
the whole file is sent with `200`, so a client never splices a piece of a
newer file onto an older download.
Asking for an existing file or directory with any other method than `GET`,
`HEAD` or `OPTIONS` gets `405 Method Not Allowed` with
`Allow: GET, HEAD, OPTIONS` rather than `404` (17.113.2).
```bash
mkdir www && echo "<h1>Hello</h1>" > www/index.html
cd www
rws
curl http://localhost:7878/ # serves index.html
curl http://localhost:7878/index.html # same file, explicit path
```
## How files are served fast
- **Open files are reused.** A static file's open handle is kept for one
second (`open_file_cache_secs` / `RWS_CONFIG_OPEN_FILE_CACHE_SECS`, read
at startup; `0` turns it off; Unix only) and read with positional
reads, so a request for a hot file costs one `fstat` instead of an `open`,
several `stat`s and a `close`. Size and modification time are read on every
request, so a file edited in place is served correctly at once; a file
replaced by a new one (a rename) or deleted is noticed within the second.
At most 128 files are held open. Files up to 64 KiB also have their
contents kept in memory (8 MiB in total), checked against the size and
modification time on every request, so an unchanged small file costs no
read; a file modified in the last two seconds is always read fresh.
- **One write per response.** A small response's head and body leave in one
system call and one TCP segment.
- **Downloads use `sendfile`** over plain HTTP on Linux and macOS: the kernel
copies the file to the socket, with the response head in the same segment
(`TCP_CORK` on Linux, `sendfile`'s header vector on macOS) and the open
file handle reused rather than reopened.
- **Static files and health checks run on the event loop**, without a hand-off
to a thread pool.
## Directory listing
**Default, always-on behavior — not gated by a feature flag or config setting.** A requested directory that has no `index.html` renders a directory listing page (`200 OK`) instead of falling through to `404 Not Found`. A directory that *does* have an `index.html` is served exactly as before — this only changes what happens for the previously-404 case.
```bash
mkdir -p www/reports && touch www/reports/q1.pdf www/reports/q2.pdf
curl http://localhost:7878/reports/
# <!DOCTYPE html> ... directory listing page ...
```
The page includes:
- A breadcrumb (`~ / reports`) linking back to each ancestor directory.
- A parent-directory link (omitted at a static root, since there's nowhere to go up to).
- A table of entries — directories first, then files, both sorted case-insensitively by name — with size and last-modified columns.
- A client-side search box that filters rows without a round-trip to the server.
Entry names are HTML-escaped for display and percent-encoded in `href` attributes, so filenames containing `<`, `&`, spaces, or other special characters render and link correctly. Dotfiles (names starting with `.`) are omitted from the listing.
```rust
use rust_web_server::app::App;
use rust_web_server::test_client::TestClient;
let client = TestClient::new(App::new());
let response = client.get("/reports/").send();
assert_eq!(200, response.status());
```
## Why the listing's CSS/JS aren't inlined
The framework's default `Content-Security-Policy: default-src 'self'` header (see [CORS & Security](/features/cors-security/)) silently blocks inline `<style>`/`<script>` blocks — a browser enforcing that policy would render the listing completely unstyled if the CSS were embedded directly in the page.
Instead, `DirectoryListingAssetsController` serves the listing's stylesheet and filter-box script as same-origin assets:
- `GET /rws-directory-listing.css`
- `GET /rws-directory-listing.js`
Both are `'self'`-origin `<link>`/`<script src>` references, fully compliant with the default CSP — no policy relaxation needed. Just like `StyleController` (`/style.css`) and `ScriptController` (`/script.js`), a file on disk at that same relative path overrides the compiled-in default:
```bash
# Restyle the directory listing without recompiling — drop a file named
# exactly "rws-directory-listing.css" in the working directory.
echo "body { font-family: monospace; }" > rws-directory-listing.css
```
If you set a CSP (`RWS_CONFIG_CSP`, or the default one via `RWS_CONFIG_SECURITY_HEADERS`) and excludes `'self'` from `style-src`/`script-src`, add an allowance for it or the listing will render unstyled the same way any other same-origin asset would under that policy.
## SPA fallback
For local development, before you have a built app to serve this way, see [Frontend Dev Server Proxy](/getting-started/frontend-dev-proxy/) for running a React/Vite dev server alongside `rws`.
**Opt-in — unset (disabled) by default.** `RWS_CONFIG_SPA_FALLBACK` serves a configured file for any `GET`/`HEAD` request that matches no real file, directory, or `path.html`, instead of `404` — the standard client-side-router ("SPA") fallback a React Router / Vue Router / etc. app needs for deep links to work:
```bash
# Build your React/Vue/etc. app, then serve its output directly:
cd dist && RWS_CONFIG_SPA_FALLBACK=index.html rws
```
```bash
curl http://localhost:7878/dashboard/settings
# no file on disk at this path — served index.html instead of 404,
# so the client-side router can take over and render /dashboard/settings
```
```rust
use rust_web_server::app::App;
use rust_web_server::test_client::TestClient;
let client = TestClient::new(App::new());
let response = client.get("/dashboard/settings").send();
assert_eq!(200, response.status());
```
A file or directory that *does* exist is always served as itself — the fallback only ever applies to what was previously a `404`, and only when the configured file actually exists on disk (a typo'd `RWS_CONFIG_SPA_FALLBACK` value is a silent no-op, not a broken response).
### Scoping — avoiding swallowed 404s
A blanket fallback would turn every typo'd URL and missing asset into a `200`, which is worse than the `404` it replaces. Two checks apply automatically:
1. **Extension heuristic** — only a request whose *last path segment* has no `.` gets the fallback. `/dashboard/settings` qualifies (a client-side route); `/assets/logo.png` (a missed static asset) still `404`s. This is the same heuristic webpack-dev-server's `historyApiFallback`, `sirv`, and `vite preview` use.
2. **`RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES`** — a comma-separated list of path prefixes that never receive the fallback, even when it's configured:
```bash
export RWS_CONFIG_SPA_FALLBACK=index.html
export RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES=/api,/healthz
```
```bash
curl http://localhost:7878/api/users/999 # 404 — excluded prefix, not rewritten
curl http://localhost:7878/settings/theme # 200 — served index.html
```
Both env vars are read fresh on every request, so they can be changed via `rws.config.toml` + `SIGHUP` without a restart — see [Hot Config Reload](/features/hot-reload/).
---
# Rate Limiting
<https://rws8.tech/features/rate-limiting/>
`rust-web-server` ships a built-in sliding-window rate limiter in `src/rate_limit/mod.rs`. It is keyed by a string (typically the client IP) and is safe to share across threads.
## Quick start
Add `RateLimitLayer` to the middleware stack to enforce the global limit on every request:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::middleware::RateLimitLayer;
let app = App::new().wrap(RateLimitLayer);
```
`RateLimitLayer` calls `rate_limit::global().check(&connection.client.ip)` on every request. When the budget is exceeded it returns `429 Too Many Requests` without forwarding to the inner application.
## Global singleton
`rate_limit::global()` returns a `&'static RateLimiter` initialized once from environment variables:
| Variable | Default | Meaning |
|---|---|---|
| `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` | `1000` | Requests allowed per window |
| `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` | `60` | Sliding window length in seconds |
```bash
RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS=500
RWS_CONFIG_RATE_LIMIT_WINDOW_SECS=30
```
or in `rws.config.toml`:
```toml
[rate_limit]
max_requests = 500
window_secs = 30
```
The `rws` binary enforces this global limit only when `max_requests` is set
to a positive number (an unset limit means no global limit, not 1000/60s).
Turning it on needs a restart; changing the numbers later is hot-reloadable
via `SIGHUP`. In your own app, add `.wrap(RateLimitLayer)` to enforce it.
## `RateLimiter` API
### `check(key) -> bool`
Records a timestamp for `key` and returns `true` when the call is within the budget. Returns `false` — without recording — when the budget is exhausted.
```rust
use rust_web_server::rate_limit::RateLimiter;
let limiter = RateLimiter::new(100, 60); // 100 req / 60 s
if limiter.check("192.168.1.1") {
// request is within budget — proceed
} else {
// return 429 Too Many Requests
}
```
### `remaining(key) -> u32`
Returns the number of requests `key` may still make in the current window without being rate-limited. Useful for `X-RateLimit-Remaining` response headers.
```rust
let left = limiter.remaining("192.168.1.1");
// add to response headers:
// X-RateLimit-Remaining: {left}
```
### `retry_after(key) -> u64`
Seconds a rejected `key` should wait before trying again. The window length — a safe upper bound, since the oldest request in the window may expire sooner. The two 429 paths (`RateLimitLayer` and `PerRouteRateLimit`) attach it to every rejection:
```
Retry-After: {window_secs}
```
### `set_limits(max_requests, window_secs)`
Updates the limits on a live limiter without a restart. Called automatically by `config_reload::reload()` on `SIGHUP`.
```rust
limiter.set_limits(200, 60); // new limit takes effect immediately
```
### `reset(key)`
Clears all recorded timestamps for `key`. Useful in tests.
```rust
limiter.reset("192.168.1.1");
```
## Hot reload
Send `SIGHUP` or `POST /admin/config/reload` to update the global limiter without restarting. The new `RWS_CONFIG_RATE_LIMIT_*` values are read from the environment and applied atomically via `set_limits`.
## Custom per-route limiter
Create a `RateLimiter` with custom parameters and call `check` in your handler directly:
```rust
use std::sync::Arc;
use rust_web_server::rate_limit::RateLimiter;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::error::{AppError, IntoResponse};
struct State {
login_limiter: RateLimiter,
}
fn login_handler(
state: &Arc,
req: &Request,
conn: &ConnectionInfo,
) -> Response {
if !state.login_limiter.check(&conn.client.ip) {
return AppError::TooManyRequests.into_response();
}
// proceed with login logic ...
Response::new()
}
// Wire up:
let state = Arc::new(State {
login_limiter: RateLimiter::new(5, 60), // 5 attempts per minute
});
```
Wrap the limiter in `Arc` to share it across handler invocations.
:::note[Per-route vs global]
`RateLimitLayer` uses the global singleton. For endpoints that need tighter limits (login, password reset, OTP verification), create a separate `RateLimiter` and call `check` inside the handler as shown above.
:::
## Distributed rate limiting with `RedisRateLimiter`
`RateLimiter` tracks state in process memory. Run two or more `rws` instances behind a load balancer and each one enforces its own budget independently — the effective limit for a given client multiplies by the number of replicas instead of staying constant. `RedisRateLimiter` fixes this by keying the counter on a shared Redis server, so every instance increments and reads the same value.
```rust
use rust_web_server::rate_limit::RedisRateLimiter;
// 100 requests / 60s, shared across every rws instance pointed at this Redis server
let limiter = RedisRateLimiter::new("127.0.0.1:6379", None, 100, 60);
match limiter.check("192.168.1.1") {
Ok(true) => { /* within budget — proceed */ }
Ok(false) => { /* budget exhausted — return 429 */ }
Err(e) => { /* Redis unreachable — decide fail-open vs fail-closed yourself */ }
}
```
`RedisRateLimiter::from_env()` builds one from environment variables:
| Variable | Default | Meaning |
|---|---|---|
| `RWS_REDIS_HOST` | `127.0.0.1` | Redis server host |
| `RWS_REDIS_PORT` | `6379` | Redis server port |
| `RWS_REDIS_PASSWORD` | *(none)* | Passed to Redis `AUTH` if set |
| `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` | `1000` | Requests allowed per window |
| `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` | `60` | Window length in seconds |
```rust
use rust_web_server::rate_limit::RedisRateLimiter;
let limiter = RedisRateLimiter::from_env();
```
### How it differs from the in-process `RateLimiter`
`RateLimiter` records a sliding-window log of timestamps per key. `RedisRateLimiter` instead uses a **fixed-window counter**: each key lives under one Redis string with a TTL equal to the window length, and every call issues an atomic `INCR`. This is the standard distributed rate-limiting pattern — Redis guarantees `INCR` is atomic across every concurrent caller, so the count stays correct no matter how many `rws` processes are incrementing it at once.
The tradeoff versus a true sliding window: a client can burst up to `2x max_requests` across a window boundary (once near the end of one window, once right after the next one starts). For most deployments this is an acceptable price for atomic, coordination-free enforcement across a cluster.
`check`, unlike `RateLimiter::check`, returns `Result<bool, std::io::Error>` — the network call to Redis can fail. Callers decide whether an unreachable Redis server should fail open (allow the request) or fail closed (reject it); `RedisRateLimiter` does not pick one for you.
### API
- `RedisRateLimiter::new(addr, password, max_requests, window_secs)` — connect to `addr` (e.g. `"127.0.0.1:6379"`).
- `RedisRateLimiter::from_env()` — build from the environment variables above.
- `check(key) -> Result<bool, io::Error>` — atomically increments the shared counter for `key` and returns whether the call is within budget.
- `remaining(key) -> Result<u32, io::Error>` — requests `key` may still make in the current window.
- `set_limits(max_requests, window_secs)` — update limits on a live limiter; takes effect on the next `check`/`remaining`.
- `reset(key) -> Result<(), io::Error>` — deletes the Redis key for `key`. Useful in tests.
Cloning a `RedisRateLimiter` is cheap — all clones share the same underlying TCP connection, mirroring `RedisSessionStore`'s connection-sharing model (see [Sessions](/features/sessions/)).
---
# Compression
<https://rws8.tech/features/compression/>
`rust-web-server` compresses text responses automatically, in the coding the
client prefers: `br` (brotli), `zstd` or `gzip` (brotli and zstd since
18.1.0). No middleware registration, no configuration.
## Which coding
The request's `Accept-Encoding` decides: the coding with the highest `q`
wins, `*` stands for any coding not listed, and `q=0` refuses one. When
several are equally acceptable — a browser sends
`gzip, deflate, br, zstd` — the server's order breaks the tie:
| Response | Order | Why |
|---|---|---|
| Static files (compressed once, then cached) | `br`, `zstd`, `gzip` | brotli's output is smallest, and it is paid for once |
| Everything else (compressed per response) | `zstd`, `br`, `gzip` | zstd costs the least CPU at a similar ratio |
A request without `Accept-Encoding` gets the response uncompressed.
Brotli and zstd are the `brotli` and `zstd` Cargo features, both on by
default (`zstd` builds the C libzstd). Without them only gzip is offered;
the `http1`-only build (`--no-default-features --features http1`) has
gzip only.
## What gets compressed
`compression::apply_compression(request, response)` runs after your
handler, before the response is written (HTTP/1.1, HTTP/2 and HTTP/3). It
compresses when:
1. The response has no `Content-Encoding` already — a backend's compressed
response is passed through, never compressed twice.
2. The body is at least 512 bytes (`compression::MIN_COMPRESS_SIZE`, the
same minimum Caddy uses).
3. The client accepts a coding (above).
4. The `Content-Type` is compressible (below).
5. The response isn't partial: a `206` is a slice of the file and is sent as
is (since 17.113.2).
Levels are the fast ones peers use for per-response compression: brotli 4,
zstd 3, gzip 6 (`compression::Level::Dynamic`; brotli's default, 11, is
about a hundred times slower than gzip). If the result isn't smaller than
the original, the original is sent. A compressed response gets:
- `Content-Encoding: br`, `zstd` or `gzip`
- `Vary: Accept-Encoding` (appended to an existing `Vary`, or added)
- its own `ETag` when it had one: `"x"` becomes `"x-br"` (since 18.1.0;
each representation needs a different strong ETag)
`compression::apply_gzip` still exists and only ever uses gzip.
## Compressible content types
Only responses whose `Content-Type` starts with one of these are compressed:
| MIME type |
|-----------|
| `text/html` |
| `text/css` |
| `text/javascript` |
| `text/plain` |
| `text/xml` |
| `application/json` |
| `application/javascript` |
| `application/xml` |
| `application/xhtml+xml` |
| `image/svg+xml` |
Images, audio, video, fonts and archives are already compressed.
## Static files are compressed once
A static file is compressed once per version per coding, not on every
request: the open-file cache keeps the brotli, zstd and gzip forms of each
file up to 1 MiB, keyed by its size and modification time, within the same
8 MiB memory budget, at the higher `Level::Static` (brotli 6, zstd 6,
gzip 6). An edit is picked up at once; a file modified less than 2 seconds
ago isn't cached yet. The `ETag` carries the coding (`"<mtime>-<size>-br"`),
so a client revalidating gets a `304` for the representation it holds.
### Precompressed files
If `app.js.br`, `app.js.zst` or `app.js.gz` sits next to `app.js`, is a
regular file (not a symlink) and is at least as new as `app.js`, its bytes
are sent for that coding instead of compressing — as nginx's `gzip_static`
does. Build them once with the best settings (`brotli -q 11`,
`zstd -19`):
```bash
brotli -q 11 -k static/app.js # static/app.js.br
zstd -19 -k static/app.js # static/app.js.zst
gzip -9 -k static/app.js # static/app.js.gz
```
A sibling older than the file (the file was edited since) is ignored, and
the file is compressed instead. Like the cached forms, this applies to
files up to 1 MiB.
## Large files
Static files larger than **256 KiB** are sent without being read into
memory — `sendfile(2)` over plain HTTP, chunked otherwise — except that a
client accepting a coding gets a compressible file of up to **1 MiB**
compressed from the cache. Larger files are sent uncompressed.
:::note[Range requests]
Byte-range requests (`Range: bytes=N-M`) are answered with the slice,
uncompressed: compressing a slice would describe bytes that don't exist.
:::
## Checking it
```bash
curl -s -D - -o /dev/null -H "Accept-Encoding: br" http://localhost:7878/app.js
# content-encoding: br
# vary: Accept-Encoding
# etag: "1793007086216830266-26624-br"
```
## No configuration needed
There are no `rws.config.toml` keys, environment variables or middleware
layers for compression.
---
# HTTPS / TLS
<https://rws8.tech/features/https-tls/>
## Feature requirements
TLS is available when you build with the `http2` or `http3` (default) feature.
The `http1`-only build has no TLS support.
```bash
# Default build — HTTP/3 + HTTP/2 + TLS
cargo build
# HTTP/2 + TLS only, no QUIC
cargo build --no-default-features --features http2
```
## Generating a self-signed certificate
Use `openssl` to create a certificate and key for local development:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost"
```
## Starting the server with TLS
Pass the certificate and key on the command line:
```bash
cargo run -- --tls-cert-file=cert.pem --tls-key-file=key.pem
```
Alternatively, set environment variables or add them to `rws.config.toml`:
```bash
export RWS_CONFIG_TLS_CERT_FILE=cert.pem
export RWS_CONFIG_TLS_KEY_FILE=key.pem
cargo run
```
```toml
# rws.config.toml
tls_cert_file = "cert.pem"
tls_key_file = "key.pem"
```
## TLS implementation
`rust-web-server` uses [rustls](https://github.com/rustls/rustls) with the
`aws-lc-rs` cryptography backend. There is no dependency on OpenSSL.
## ALPN negotiation
When a TLS certificate is configured, the server advertises both `h2` and
`http/1.1` via ALPN in the TLS handshake. A single port handles HTTP/2 and
HTTP/1.1 simultaneously — no extra configuration is required.
```
alpn_protocols = ["h2", "http/1.1"]
```
The HTTP/2 server (`h2_server`) turns HTTP/2 streams into the same `Request` /
`Application::execute` / `Response` pipeline that HTTP/1.1 uses.
## HTTP → HTTPS redirect
Set `RWS_CONFIG_HTTP_REDIRECT_PORT` to have the server also listen on a plain
HTTP port and issue `301 Moved Permanently` redirects to HTTPS:
```bash
export RWS_CONFIG_HTTP_REDIRECT_PORT=80
```
```toml
# rws.config.toml
http_redirect_port = "80"
```
`Server::run_redirect()` binds the redirect listener and sends every incoming
request to the HTTPS port with a `301` response.
## Alt-Svc advertisement
HTTP/1.1 TLS responses include an `Alt-Svc` header so clients learn that a
faster protocol is available:
- HTTP/3 build: `Alt-Svc: h3=":7878"`
- HTTP/2-only build: `Alt-Svc: h2=":7878"`
Browsers that support HTTP/3 will upgrade automatically on subsequent requests.
## Configuration reference
| Variable | Config key | Description |
|---|---|---|
| `RWS_CONFIG_TLS_CERT_FILE` | `tls_cert_file` | Path to the PEM certificate chain |
| `RWS_CONFIG_TLS_KEY_FILE` | `tls_key_file` | Path to the PEM private key |
| `RWS_CONFIG_HTTP_REDIRECT_PORT` | `http_redirect_port` | Plain HTTP port that issues 301 → HTTPS |
:::note[Hot reload]
Send `SIGHUP` or `POST /admin/config/reload` to reload the TLS certificate
from disk without restarting the server. This also rebuilds the TLS acceptor
for all configured virtual hosts.
:::
## Upstream TLS (proxy mode)
When using the config-driven proxy (`rws.config.toml`), prefix backend addresses with `https://` to connect to upstream services over TLS:
```toml
[[upstream]]
name = "secure-api"
backends = ["https://api.internal:443", "https://api2.internal:443"]
```
Certificate verification uses the WebPKI root store (same trust anchors as browsers). To trust a private CA instead, set `tls_ca_file` on the `[[upstream]]` (since 18.0.0). Requires the `http-client` or `http2` feature — included in all builds except `--no-default-features --features http1`.
See [Config-Driven Proxy → TLS upstreams](/proxy/config-driven/#tls-https-upstreams) for the full reference.
---
# Automatic HTTPS (ACME)
<https://rws8.tech/features/acme/>
`rws` obtains certificates from Let's Encrypt (or any ACME CA) by itself and
keeps them renewed. Point a domain's DNS at the server, then:
```toml
# rws.config.toml
port = 443
http_redirect_port = 80 # HTTP → HTTPS redirects; also answers ACME challenges
[acme]
domains = ["example.com", "www.example.com"]
email = "you@example.com"
```
```bash
rws
```
On first start `rws` requests a certificate, stores it in `.rws/acme/`
(`cert.pem`, `key.pem`, `account.key` — keys `0600`, the directory `0700`,
and never served: the working directory is the web root), and starts serving HTTPS, HTTP/2, and
HTTP/3 with it. Later starts reuse the stored certificate. Renewal happens in
the background and is hot-swapped without dropping connections.
:::note[Which binaries include ACME]
The prebuilt binaries (Homebrew, `cargo binstall`, Docker, GitHub Releases)
include it. When building from source, enable the `acme` feature:
`cargo install rust-web-server --features acme,auth`. A binary built without
it prints a warning if `[acme]` is configured instead of silently serving
plain HTTP.
:::
## Requirements
- **DNS** for every domain in `domains` points at this server.
- **Port 80 is reachable from the internet.** Let's Encrypt proves you control
the domain with an HTTP-01 challenge to `http://<domain>/.well-known/acme-challenge/…`.
- **Binding ports 80/443 on Linux** needs root or the capability:
`sudo setcap cap_net_bind_service=+ep "$(which rws)"`.
IPv6 is handled: when a domain has an AAAA record, Let's Encrypt validates
over IPv6, so the challenge and redirect listeners accept both IPv4 and IPv6.
## How it works
1. **Startup.** If no certificate exists, or it is due for renewal (see 4),
`rws` runs the ACME flow before opening the HTTPS
port: account (key saved to `.rws/acme/account.key`), order, HTTP-01 challenge,
ECDSA P-256 key + CSR, finalize, download.
2. **Challenges.** At startup a temporary listener on the challenge port
(default 80) answers them. Once the HTTP → HTTPS redirect listener is
running on that port, it answers challenges itself — so renewals never
compete with it for port 80.
3. **First-time failures are retried**, e.g. while DNS for a new domain is
still propagating: after 30 s, doubling up to every 15 minutes, until a
certificate is issued.
4. **Renewal** (since 18.0.0). A certificate is renewed at two-thirds of its
lifetime — day 60 of 90, day 30 of 45, day 4 of 6 — or earlier when the
CA's ACME Renewal Information ([RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html))
suggests a window that opens sooner, e.g. after a revocation. `rws` asks
for it at every check (every 12 hours at most, every 6 when the CA has
renewal information, sooner when renewal is due earlier or the CA's
`Retry-After` says so) and picks a point in the window that's fixed per
certificate. The renewal order names the certificate it replaces, so the
CA can exempt it from rate limits. A failed renewal is retried within an
hour. After renewing it reloads the TLS certificate in place (on Unix, via
the same `SIGHUP` reload used for config). `renew_before_days`, if set,
replaces the two-thirds point (a CA window that opens earlier still wins).
5. **Profiles.** `profile` orders a certificate profile the CA offers —
Let's Encrypt has `classic`, `tlsserver` and `shortlived` (6-day
certificates). A name the CA doesn't list stops provisioning with the list
it offers. Unset, the CA picks its default.
The ACME client (RFC 8555) is implemented in-crate. It has been tested end to
end against [Pebble](https://github.com/letsencrypt/pebble), Let's Encrypt's
test CA, including a `shortlived` profile and renewal on Pebble's renewal
information through the redirect listener.
## Testing with the staging CA
Let's Encrypt production has strict
[rate limits](https://letsencrypt.org/docs/rate-limits/). Try your setup
against staging first — its certificates aren't browser-trusted, but the
limits are generous:
```toml
[acme]
domains = ["example.com"]
staging = true
```
Delete `.rws/acme/` before switching back to production.
Before 17.113.1 the defaults were `cert.pem`, `key.pem` and `acme_account.key`
in the working directory — the web root, so the keys could be downloaded.
On upgrade, files found there are moved into `.rws/acme/` (when the default
paths are in use), and the static server no longer serves `*.pem`, `*.key`
or `rws.config.toml` anywhere. Rotate a key that was exposed.
## Private ACME CAs
To use step-ca, an internal Boulder, or Pebble, set the directory URL and
trust the CA's root certificate (in addition to the public roots):
```toml
[acme]
domains = ["internal.example"]
directory = "https://ca.internal.example:9000/acme/acme/directory"
ca_file = "/etc/rws/internal-root.pem"
```
## Configuration reference
Every setting can go in the `[acme]` section of `rws.config.toml`, on the
command line (`--acme-domains=...`; not the last three), or in the environment.
| `[acme]` key | Environment variable | Default | Description |
|---|---|---|---|
| `domains` | `RWS_CONFIG_ACME_DOMAINS` | — | Domains for the certificate. Setting this enables ACME. |
| `email` | `RWS_CONFIG_ACME_EMAIL` | — | Contact email for expiry notices from the CA |
| `staging` | `RWS_CONFIG_ACME_STAGING` | `false` | Use the Let's Encrypt staging CA |
| `directory` | `RWS_CONFIG_ACME_DIRECTORY` | Let's Encrypt | ACME directory URL of another CA |
| `ca_file` | `RWS_CONFIG_ACME_CA_FILE` | — | Extra root CA(s) to trust for the directory (private CAs) |
| `challenge_port` | `RWS_CONFIG_ACME_CHALLENGE_PORT` | `80` | Port the CA connects to for HTTP-01 |
| `cert_path` | `RWS_CONFIG_ACME_CERT_PATH` | `tls_cert_file`, else `.rws/acme/cert.pem` | Where the certificate chain is stored |
| `key_path` | `RWS_CONFIG_ACME_KEY_PATH` | `tls_key_file`, else `.rws/acme/key.pem` | Where the certificate key is stored (`0600`) |
| `renew_before_days` | `RWS_CONFIG_ACME_RENEW_BEFORE_DAYS` | — | Renew this many days before expiry instead of at two-thirds of the lifetime (was `30` before 18.0.0) |
| `profile` | `RWS_CONFIG_ACME_PROFILE` | — | Certificate profile to order (`classic`, `tlsserver`, `shortlived`, … as the CA offers) |
| `renew_check_interval_secs` | `RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS` | `43200` | Longest time between renewal checks |
| `account_key_path` | `RWS_CONFIG_ACME_ACCOUNT_KEY_PATH` | `.rws/acme/account.key` | Where the ACME account key is stored (`0600`) |
Top-level `http_redirect_port` (`RWS_CONFIG_HTTP_REDIRECT_PORT`) starts the
HTTP → HTTPS redirect listener; set it to the challenge port (80) in
production.
## Using ACME from Rust
The `rws` binary does all of the above. In your own binary built on the
library, the equivalent is:
```rust
use rust_web_server::acme::{AcmeConfig, AcmeManager};
use rust_web_server::prelude::*;
#[tokio::main]
async fn main() {
let (listener, pool) = Server::setup().unwrap();
if let Some(cfg) = AcmeConfig::from_env() {
// Serve the issued certificate unless TLS paths were set explicitly.
cfg.install_as_tls_certificate();
let mgr = AcmeManager::new(cfg);
mgr.provision_until_ready().await; // retries until issued
tokio::spawn(mgr.run_renewal_loop());
}
let app = App::new();
tokio::join!(
Server::run_tls(listener, pool, app.clone()),
Server::run_quic(app),
Server::run_redirect(), // answers renewal challenges
);
}
```
`AcmeManager::provision_if_needed()` (single attempt, returns the error) is
also available if you want to handle failures yourself.
---
# Mutual TLS (mTLS)
<https://rws8.tech/features/mtls/>
## Overview
Mutual TLS (mTLS) enforces two-way authentication: the server presents its
certificate to the client as usual, and the client must also present a
certificate signed by a CA that the server trusts. Connections that do not
present a valid client certificate are rejected at the TLS handshake layer,
before any HTTP traffic is exchanged.
## Enabling mTLS
Set `RWS_CONFIG_TLS_CLIENT_CA_FILE` to the path of a PEM file containing the
CA certificate (or certificate chain) whose signatures you trust:
```bash
export RWS_CONFIG_TLS_CLIENT_CA_FILE=/etc/ssl/client-ca.pem
```
```toml
# rws.config.toml
tls_client_ca_file = "/etc/ssl/client-ca.pem"
```
No other configuration is needed. When this variable is set,
`create_tls_acceptor_from_vhosts()` builds a `WebPkiClientVerifier` and
attaches it to the `rustls::ServerConfig`. The same CA applies to both the
HTTPS (TCP/TLS) listener and the QUIC listener used for HTTP/3.
## How the verifier is built
```rust
// src/tls/mod.rs (simplified)
let verifier = WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.unwrap();
ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_cert_resolver(Arc::new(resolver))
```
The CA certificate is loaded into a `RootCertStore`. Every TLS `ClientHello`
that follows must include a certificate chain rooted in that store.
## Generating test client certificates
Create a self-signed CA and a client certificate signed by it:
```bash
# 1. Generate a CA key and self-signed certificate
openssl req -x509 -newkey rsa:4096 -keyout ca-key.pem -out ca.pem \
-days 3650 -nodes -subj "/CN=MyTestCA"
# 2. Generate a client key and certificate signing request
openssl req -newkey rsa:2048 -keyout client-key.pem -out client.csr \
-nodes -subj "/CN=test-client"
# 3. Sign the client certificate with the CA
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out client.pem -days 365
```
## Testing with curl
```bash
curl --cert client.pem --key client-key.pem \
--cacert cert.pem \
https://localhost:7878/healthz
```
`--cacert cert.pem` tells curl to trust the server's self-signed certificate.
`--cert` / `--key` provide the client certificate and key that the server
validates against `client-ca.pem`.
## Scope
mTLS applies globally to all connections on the TLS port. There is no per-route
or per-virtual-host granularity at the TLS layer; use application middleware
(e.g., `JwtLayer` or a custom `Middleware`) for finer-grained access control
within a single TLS session.
:::note[Plain HTTP connections]
mTLS only applies to TLS connections. Plain HTTP/1.1 connections (when no cert
is configured, or connections on a redirect port) are not affected.
:::
## Configuration reference
| Variable | Config key | Description |
|---|---|---|
| `RWS_CONFIG_TLS_CLIENT_CA_FILE` | `tls_client_ca_file` | Path to the CA PEM file whose signatures are trusted for client certificates |
---
# Virtual Hosting
<https://rws8.tech/features/virtual-hosting/>
## Overview
A single `rust-web-server` instance can host multiple domains simultaneously.
At the TLS handshake, the client sends its target hostname via SNI (Server Name
Indication). `SniCertResolver` reads that hostname and selects the matching
certificate before any HTTP traffic is exchanged. The negotiated hostname is
then available in every request handler as `ConnectionInfo::sni_hostname`.
Virtual hosting requires the `http2` or `http3` (default) feature for TLS
support.
## Configuring virtual hosts in `rws.config.toml`
Add one `[[virtual_host]]` block per domain. The top-level `tls_cert_file` /
`tls_key_file` pair is used as a fallback when no SNI hostname matches (or when
the client sends no SNI):
```toml
# Default certificate — used when no virtual host matches
tls_cert_file = "/etc/ssl/default.pem"
tls_key_file = "/etc/ssl/default.key"
[[virtual_host]]
domain = "example.com"
cert_file = "/etc/ssl/example.pem"
key_file = "/etc/ssl/example.key"
[[virtual_host]]
domain = "api.example.com"
cert_file = "/etc/ssl/api-example.pem"
key_file = "/etc/ssl/api-example.key"
```
## Configuring virtual hosts via environment variables
The same configuration is available through numbered environment variables,
which is convenient in container environments:
```bash
RWS_CONFIG_VIRTUAL_HOST_0_DOMAIN=example.com
RWS_CONFIG_VIRTUAL_HOST_0_CERT_FILE=/etc/ssl/example.pem
RWS_CONFIG_VIRTUAL_HOST_0_KEY_FILE=/etc/ssl/example.key
RWS_CONFIG_VIRTUAL_HOST_1_DOMAIN=api.example.com
RWS_CONFIG_VIRTUAL_HOST_1_CERT_FILE=/etc/ssl/api-example.pem
RWS_CONFIG_VIRTUAL_HOST_1_KEY_FILE=/etc/ssl/api-example.key
```
## How SNI resolution works
`SniCertResolver` implements `rustls::server::ResolvesServerCert`. It holds a
`HashMap>` keyed by the exact SNI hostname, plus an
optional default. The resolver is built once at startup (or after SIGHUP) by
`create_tls_acceptor_from_vhosts()`:
```rust
// src/tls/mod.rs (simplified)
pub fn create_tls_acceptor_from_vhosts(
vhosts: &[VirtualHostConfig],
default_cert: &str,
default_key: &str,
) -> Result```
HTTP/3 (QUIC) uses the same `SniCertResolver` via
`create_quinn_server_config_from_vhosts()`, so virtual hosting works
transparently across all protocols.
## Reading the SNI hostname in handlers
After the TLS handshake, `ConnectionInfo::sni_hostname` carries the negotiated
hostname as `Option`. For plain HTTP/1.1 connections (no TLS) this
field is `None` and the `Host` header should be used instead.
```rust
fn process(&self, request: &Request, response: Response, connection: &ConnectionInfo) -> Response {
match &connection.sni_hostname {
Some(host) => println!("Serving request for: {}", host),
None => println!("No SNI — plain HTTP or no matching vhost"),
}
response
}
```
## Host-restricted routing with `Router`
Call `.with_host("hostname")` before registering routes to restrict a `Router`
to requests whose SNI hostname (TLS) or `Host` header (plain HTTP) matches:
```rust
use rust_web_server::router::Router;
let api_router = Router::new()
.with_host("api.example.com")
.get("/v1/users", |_req, _params, _conn| { /* ... */ });
let www_router = Router::new()
.with_host("example.com")
.get("/", |_req, _params, _conn| { /* ... */ });
```
`router.handle(request, connection)` returns `None` when the host or path
doesn't match, so an `Application` can try each domain's router in turn and
fall through to the built-in `App` (static files, `/healthz`, …):
```rust
use rust_web_server::app::App;
use rust_web_server::application::Application;
use rust_web_server::core::New;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::Router;
use rust_web_server::server::ConnectionInfo;
pub struct VhostApp {
example: Router,
other: Router,
fallback: App,
}
impl VhostApp {
pub fn new() -> Self {
VhostApp {
example: Router::new().with_host("example.com").get("/", |_req, _p, _c| {
Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "example.com home")
}),
other: Router::new().with_host("other.com").get("/", |_req, _p, _c| {
Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "other.com home")
}),
fallback: App::new(),
}
}
}
impl Application for VhostApp {
fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result {
if let Some(r) = self.example.handle(request, connection) { return Ok(r); }
if let Some(r) = self.other.handle(request, connection) { return Ok(r); }
self.fallback.execute(request, connection)
}
}
```
## Hot reload
Send `SIGHUP` (or `POST /admin/config/reload`) to hot-reload all virtual host
certificates from disk without restarting the server:
```bash
kill -HUP $(pidof rws)
```
`Server::run_tls` rebuilds `TlsAcceptor` with updated certificates for all
virtual hosts on every SIGHUP. New connections immediately use the refreshed
certificates; existing connections are not interrupted.
---
# HTTP/2
<https://rws8.tech/features/http2/>
## Feature requirements
HTTP/2 support is included in the `http2` feature and the default `http3`
feature:
```bash
# HTTP/2 + TLS only (no QUIC)
cargo build --no-default-features --features http2
# Default build — also includes HTTP/3
cargo build
```
HTTP/2 is only available over TLS. A valid certificate must be configured.
See [HTTPS / TLS](/features/https-tls) for setup instructions.
## ALPN negotiation
No extra configuration is required to serve HTTP/2. The server advertises both
`h2` and `http/1.1` in the TLS handshake via ALPN. The client's preference
determines which protocol is selected for each connection:
```
alpn_protocols = ["h2", "http/1.1"]
```
Both protocols share the same port. Browsers, `curl`, and other HTTP/2-capable
clients negotiate HTTP/2 automatically.
## Cleartext HTTP/2 (h2c)
Without TLS, a client can still speak HTTP/2 by sending the HTTP/2
connection preface straight away ("prior knowledge") — gRPC clients and
`grpcurl -plaintext` do. Since 18.3.0 rws's plain-HTTP listener recognises
the preface and serves the connection as HTTP/2; anything else is HTTP/1.1
as before. The `Upgrade: h2c` handshake (removed from HTTP/2 in RFC 9113)
isn't supported.
```bash
curl --http2-prior-knowledge http://localhost:7878/healthz
grpcurl -plaintext -proto echo.proto localhost:7878 echo.Echo/Unary
```
## Request pipeline
`rws` has its own HTTP/2 server (`h2_server`, since 18.0.0 — the `h2` crate is
now only the HTTP/2 client of the proxies). Per connection it:
1. Exchanges the connection preface and SETTINGS (at most 250 concurrent
streams, header lists up to 64 KiB — larger ones get `431`).
2. Reads frames, decodes HPACK header blocks and assembles each stream's
`Request` (body included), validating it as RFC 9113 §8.3 requires.
3. Calls your `Application` — inline for a quick bodiless request the app
says it can answer inline (`runs_inline`, as on HTTP/1.1), otherwise in its
own task.
4. Encodes the `Response` (headers seen before become one-byte HPACK indexes)
and sends it within the client's flow-control windows; file and streamed
bodies go out in chunks. Everything ready at once is written in one go.
It passes h2spec's 146 conformance tests and is fuzzed (frames and HPACK).
Abusive clients are cut off: rapid stream resets, CONTINUATION floods and
oversized frames end the connection with `GOAWAY`.
Your application code is unchanged between HTTP/1.1 and HTTP/2.
## Forbidden headers
The HTTP/2 specification (RFC 9113 §8.2.2) prohibits certain connection-level
headers in responses. The following headers are stripped automatically before
sending any H2 response:
- `connection`
- `keep-alive`
- `transfer-encoding`
- `upgrade`
- `proxy-connection`
- `te`
You do not need to remove these headers from `Response` objects yourself.
## Alt-Svc advertisement
When built without HTTP/3, HTTP/1.1 TLS responses include:
```
Alt-Svc: h2=":7878"
```
This tells HTTP/1.1 clients that HTTP/2 is available on the same port. With
the default `http3` build the header advertises `h3` instead.
## HTTP/2 reverse proxy
`H2ReverseProxy` provides an upstream proxy that connects to backends over
HTTP/2. It is available as middleware and wraps the normal
`Middleware` / `Application` stack:
```rust
use rust_web_server::proxy::H2ReverseProxy;
let proxy = H2ReverseProxy::new(vec!["h2://backend:8080".to_string()]);
let app = App::new().wrap(proxy);
```
`H2ReverseProxy` bridges between the synchronous middleware calling
convention and the async tokio runtime via an internal `block_on_isolated`
helper — a scoped OS thread running its own single-threaded tokio runtime,
rather than `tokio::task::block_in_place`. This works under any tokio
runtime flavor (including `current_thread`, where `block_in_place` would
panic) and even with no runtime active at all.
## Async handlers
The `http2` feature enables `App::with_async_state(S)`, which accepts `async fn`
handlers. This lets you `await` futures (database calls, outbound HTTP requests,
etc.) directly inside route handlers:
```rust
let app = App::with_async_state(my_state)
.get("/data", |_req, _params, _conn, state| async move {
let rows: Vec<u8> = state.db.fetch_all_json().await;
Response::json(STATUS_CODE_REASON_PHRASE.n200_ok, rows)
});
```
:::note[SNI in HTTP/2]
The SNI hostname is extracted from the TLS handshake and available in
`ConnectionInfo::sni_hostname` for every HTTP/2 request, the same as for
HTTP/1.1 TLS connections.
:::
## Limits and conformance
Each HTTP/2 connection advertises `SETTINGS_MAX_CONCURRENT_STREAMS = 250`
(the same limit as Go's `net/http`), so one client can't open unbounded
simultaneous streams, and `SETTINGS_MAX_HEADER_LIST_SIZE = 64 KiB`, so one
request can't make the server buffer megabytes of headers (HTTP/3 uses the
same 64 KiB field-section limit; an HTTP/1.1 request head is capped at
64 KiB, else `431`). CI runs [h2spec](https://github.com/summerwind/h2spec)
against a live server on every commit and requires all 146 tests to pass
with none skipped (`ci/e2e/h2spec.sh`).
---
# HTTP/3 / QUIC
<https://rws8.tech/features/http3-quic/>
## Feature requirements
HTTP/3 is included in the default build. No flags are needed:
```bash
cargo build
cargo run -- --tls-cert-file=cert.pem --tls-key-file=key.pem
```
HTTP/3 requires a valid TLS certificate. Without one, the QUIC listener is
silently skipped and the server continues to serve HTTP/2 and HTTP/1.1 over TLS.
## How it works
The `http3` feature (which implies `http2`) starts two listeners on the same
port number:
- A TCP listener for HTTP/2 and HTTP/1.1 (via `Server::run_tls`).
- A UDP listener for QUIC / HTTP/3 (via `Server::run_quic`).
Both share the same port. Clients that support HTTP/3 connect via UDP; others
fall back to TCP transparently. `main()` runs both listeners concurrently:
```rust
tokio::join!(
Server::run_tls(listener, pool, app.clone()),
Server::run_quic(app.clone()),
Server::run_redirect(), // optional HTTP→HTTPS redirect
);
```
## Dependencies
HTTP/3 is built on the following crates:
- `quinn` — async QUIC transport
- `h3` — HTTP/3 framing over QUIC streams
- `h3-quinn` — bridge between `quinn` and `h3`
All parsing and protocol handling is contained in `src/h3_handler/mod.rs`.
## SNI from the QUIC handshake
The SNI hostname is extracted from the QUIC connection's TLS handshake data
and placed in `ConnectionInfo::sni_hostname`, exactly as it is for HTTP/2:
```rust
// src/h3_handler/mod.rs
let sni_hostname: Option = conn
.handshake_data()
.and_then(|d| d.downcast::<quinn::crypto::rustls::HandshakeData>().ok())
.and_then(|d| d.server_name.clone());
```
Virtual host routing via `Router::with_host()` and direct access in handlers
via `connection.sni_hostname` work the same way for HTTP/3 as for HTTP/2.
## Alt-Svc advertisement
HTTP/1.1 TLS responses include:
```
Alt-Svc: h3=":7878"
```
This header tells clients — including browsers — that HTTP/3 is available on
the same port number over UDP. No client configuration is required; browsers
such as Chrome and Firefox will upgrade to HTTP/3 automatically on subsequent
requests after seeing this header.
## Forbidden headers
Like HTTP/2, HTTP/3 (RFC 9114 §4.2) prohibits connection-level headers.
The same set is stripped automatically before sending H3 responses:
- `connection`
- `keep-alive`
- `transfer-encoding`
- `upgrade`
- `proxy-connection`
- `te`
## Request pipeline
`h3_handler::handle_connection` mirrors the HTTP/2 handler:
1. Extracts SNI from the QUIC handshake.
2. Wraps the `quinn::Connection` in an `h3::server::Connection`.
3. Resolves each request stream with `resolver.resolve_request()`.
4. Assembles a `Request`, calls `app.execute()`, applies gzip compression, and
sends the `Response` back as H3 frames.
Your `Application` implementation is called identically for HTTP/1.1, HTTP/2,
and HTTP/3.
:::note[Firewall requirements]
HTTP/3 uses UDP. Ensure your firewall or cloud security group allows UDP
traffic on the server port (default 7878) in addition to TCP.
:::
---
# WebSocket
<https://rws8.tech/features/websocket/>
## Overview
`rust-web-server` includes a complete WebSocket implementation in
`src/websocket/mod.rs`. It covers upgrade detection, the RFC 6455 opening
handshake (SHA-1 + base64 key derivation), and frame-level read/write for all
standard opcodes.
## Integration pattern
WebSocket connections require taking over the raw TCP stream after the 101
upgrade response. They are handled in a custom accept loop rather than through
the normal `Controller` / `Application` pipeline (which has no access to the
underlying stream).
```rust
use std::io::{Read, Write};
use std::net::TcpListener;
use rust_web_server::request::Request;
use rust_web_server::websocket::{WebSocket, Frame};
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let mut stream = stream.unwrap();
// Read the HTTP upgrade request.
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).unwrap();
let request = Request::parse(&buf[..n]).unwrap();
if WebSocket::is_upgrade_request(&request) {
// Send 101 Switching Protocols.
let mut response = WebSocket::handshake_response(&request).unwrap();
stream.write_all(&response.generate()).unwrap();
// Enter the frame loop.
loop {
match WebSocket::read_frame(&mut stream) {
Ok(Frame::Text(msg)) => {
WebSocket::write_frame(&mut stream, Frame::Text(msg)).unwrap();
}
Ok(Frame::Ping(data)) => {
WebSocket::write_frame(&mut stream, Frame::Pong(data)).unwrap();
}
Ok(Frame::Close(code, reason)) => {
WebSocket::send_close(&mut stream, code.unwrap_or(1000), &reason).ok();
break;
}
Ok(_) => {}
Err(_) => break,
}
}
}
}
```
## Detecting upgrade requests
```rust
WebSocket::is_upgrade_request(request: &Request) -> bool
```
Returns `true` when the request has all three required headers:
- `Upgrade: websocket`
- `Connection: Upgrade` (or any value containing "upgrade")
- `Sec-WebSocket-Key: <base64>`
## Handshake response
```rust
WebSocket::handshake_response(request: &Request) -> Result```
Builds an HTTP `101 Switching Protocols` response with the correct
`Sec-WebSocket-Accept` value. The accept value is computed as:
```
base64(SHA-1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
```
Both SHA-1 and base64 are implemented from scratch in `src/websocket/mod.rs`
with no external dependencies.
If the request includes a `Sec-WebSocket-Protocol` header, the first listed
sub-protocol is echoed back in the response.
## Frame types
`Frame` is an enum covering all RFC 6455 opcodes:
| Variant | Opcode | Description |
|---|---|---|
| `Frame::Text(String)` | `0x1` | A complete UTF-8 text message |
| `Frame::Binary(Vec<u8>)` | `0x2` | A binary message |
| `Frame::Ping(Vec<u8>)` | `0x9` | Ping — respond with a matching Pong |
| `Frame::Pong(Vec<u8>)` | `0xA` | Pong |
| `Frame::Close(Option<u16>, String)` | `0x8` | Close with optional status code and reason |
| `Frame::Continuation { fin, data }` | `0x0` | Continuation fragment for fragmented messages |
## Reading frames
```rust
WebSocket::read_frame(stream: &mut impl Read) -> Result```
Reads one complete frame. Client-to-server masking (required by RFC 6455) is
handled automatically — the payload is unmasked before being returned. Extended
payload lengths (16-bit and 64-bit) are supported.
A frame whose declared payload is larger than `WebSocket::MAX_FRAME_PAYLOAD`
(16 MiB) is an error, returned before any of the payload is read, and the
payload is read as it arrives rather than allocated at the size the client
claims (since 18.1.0; before, a frame claiming an enormous length made the
process abort). For another limit:
```rust
WebSocket::read_frame_limited(stream: &mut impl Read, max_payload: u64) -> Result```
## Writing frames
```rust
WebSocket::write_frame(stream: &mut impl Write, frame: Frame) -> Result<(), String>
```
Writes one frame. Server-to-client frames are sent unmasked per the RFC.
Payload length encoding (7-bit, 16-bit, 64-bit) is chosen automatically.
## Convenience methods
```rust
// Send a text message
WebSocket::send_text(&mut stream, "hello")?;
// Send a close frame
WebSocket::send_close(&mut stream, 1000, "normal closure")?;
// Reply to a Ping with a Pong
WebSocket::send_pong(&mut stream, ping_payload)?;
```
## Complete echo server example
```rust
use std::net::TcpListener;
use std::io::Read;
use rust_web_server::request::Request;
use rust_web_server::websocket::{WebSocket, Frame};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
println!("WebSocket echo server listening on ws://127.0.0.1:7878");
for stream in listener.incoming() {
std::thread::spawn(|| {
let mut stream = stream.unwrap();
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).unwrap();
let request = Request::parse(&buf[..n]).unwrap();
if !WebSocket::is_upgrade_request(&request) { return; }
let mut handshake = WebSocket::handshake_response(&request).unwrap();
use std::io::Write;
stream.write_all(&handshake.generate()).unwrap();
loop {
match WebSocket::read_frame(&mut stream) {
Ok(Frame::Text(msg)) => {
WebSocket::write_frame(&mut stream, Frame::Text(msg)).unwrap();
}
Ok(Frame::Binary(data)) => {
WebSocket::write_frame(&mut stream, Frame::Binary(data)).unwrap();
}
Ok(Frame::Ping(data)) => {
WebSocket::send_pong(&mut stream, data).unwrap();
}
Ok(Frame::Close(code, reason)) => {
WebSocket::send_close(&mut stream, code.unwrap_or(1000), &reason).ok();
break;
}
_ => break,
}
}
});
}
}
```
:::note[WebSocket proxy]
`src/ws_proxy/mod.rs` provides `WsProxy` — a standalone WebSocket proxy that
forwards upgrade requests and relays raw bytes bidirectionally between the
client and a backend server.
:::
---
# Server-Sent Events
<https://rws8.tech/features/sse/>
## Quick start
```rust
use rust_web_server::sse::{Sse, SseEvent};
fn handle_updates(_req: &Request, _conn: &ConnectionInfo) -> Response {
Sse::new()
.event("connected", "ready")
.push(SseEvent::data(r#"{"count":1}"#).id("1").event_type("update"))
.push(SseEvent::data(r#"{"count":2}"#).id("2").event_type("update"))
.comment("keep-alive")
.into_response()
}
```
## `Sse` builder
`Sse::new()` creates an empty builder. Every method takes `self` and returns `Self` for chaining. Call `.into_response()` at the end to get a `Response`.
| Method | Wire output |
|---|---|
| `.event(event_type, data)` | `event: <type>\ndata: <data>\n\n` |
| `.data(data)` | `data: <data>\n\n` (no `event:` field — client sees `"message"`) |
| `.push(SseEvent)` | fully configured event |
| `.retry(ms)` | `retry: <ms>\n\n` — overrides client reconnect timer |
| `.comment(text)` | `: <text>\n\n` — ignored by browsers, used as keep-alive ping |
`.into_response()` sets:
- `200 OK`
- `Content-Type: text/event-stream`
- `Cache-Control: no-cache`
- `X-Accel-Buffering: no` — disables nginx proxy buffering
## `SseEvent`
Build with `SseEvent::data(payload)` and chain optional setters:
```rust
let event = SseEvent::data(r#"{"token":"Hello"}"#)
.id("42")
.event_type("token")
.retry(3000);
```
| Field | Type | Meaning |
|---|---|---|
| `data` | `String` (required) | Payload; multi-line strings split into multiple `data:` lines |
| `id` | `Option` | Event ID; client sends `Last-Event-ID` on reconnect |
| `event_type` | `Option` | Event type; client listens with `addEventListener("type", fn)` |
| `retry_ms` | `Option<u32>` | Reconnect delay in milliseconds |
### Multi-line data
Newlines in the `data` string are split into separate `data:` lines per the SSE specification. The client concatenates them with `\n`:
```rust
SseEvent::data("line one\nline two\nline three")
// Wire: data: line one\ndata: line two\ndata: line three\n\n
```
## Use case: streaming AI tokens
For live token-by-token output where events arrive over time, write the SSE headers and raw event lines directly to the TCP stream. The buffered `Sse` builder is for pre-known event sequences; live streaming requires a custom accept loop:
```rust
// 1. Emit SSE headers
response.headers.push(Header::new("Content-Type", "text/event-stream"));
response.headers.push(Header::new("Cache-Control", "no-cache"));
response.headers.push(Header::new("X-Accel-Buffering", "no"));
write_headers(&mut stream, &response);
// 2. Loop over model output and push each token
for token in model_stream {
let event = SseEvent::data(&token).encode();
stream.write_all(&event)?;
stream.flush()?;
}
```
## Client-side JavaScript
```js
const source = new EventSource("/updates");
// Default handler — fires for events with no `event:` type field
source.onmessage = (e) => {
console.log("message:", e.data);
};
// Named event handler
source.addEventListener("update", (e) => {
const payload = JSON.parse(e.data);
console.log("update count:", payload.count);
});
// Reconnection — browser retries automatically; Last-Event-ID is sent
source.onerror = () => console.warn("SSE connection lost, retrying…");
```
:::note[Buffered vs. live streaming]
`Sse::into_response()` buffers all events before writing. It suits pre-known sequences like progress steps or batch pushes. For continuous output (AI tokens, sensor feeds) write raw SSE frames directly to the open TCP connection instead.
:::
---
# Authentication
<https://rws8.tech/features/auth/>
The `auth` Cargo feature adds `BasicAuthLayer`, `JwtLayer`, and `ForwardAuthLayer` middleware, plus standalone helpers for building and verifying HS256 JWTs. The `auth-asymmetric` feature (on top of `auth`) adds `JwtLayer::rs256`/`::es256` for verifying RS256/ES256 tokens against a static public key.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["auth"] }
# or, for RS256/ES256 support too:
rust-web-server = { version = "18", features = ["auth-asymmetric"] }
```
The `auth` feature pulls in `hmac` and `sha2` (RustCrypto) as additional dependencies. `auth-asymmetric` additionally pulls in `p256` (also RustCrypto) for ES256; RS256 is verified in-crate (`rust_web_server::rsa_key::RsaPublicKey`), with no `rsa` crate.
## HTTP Basic Auth
`BasicAuthLayer` validates `Authorization: Basic <base64>` credentials against a caller-supplied closure. It issues `401 Unauthorized` with a `WWW-Authenticate: Basic realm="Protected"` challenge when the header is absent or malformed.
```rust
use rust_web_server::app::App;
use rust_web_server::auth::BasicAuthLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(BasicAuthLayer::new(|user, pass| {
user == "admin" && pass == "s3cret"
}));
```
The closure signature is `Fn(&str, &str) -> bool + Send + Sync + 'static`. Passwords containing `:` are handled correctly — only the first `:` splits username from password, per RFC 7617.
Credential check outcomes:
| Condition | Response |
|---|---|
| `Authorization` header absent or malformed | `401` + `WWW-Authenticate` challenge |
| Header present but closure returns `false` | `401` (no challenge) |
| Closure returns `true` | Request forwarded to next layer |
### Validating against an htpasswd-style file
`BasicAuthLayer::from_htpasswd_file(path)` loads credentials from a file instead of a closure — the file is read once, at construction time:
```rust
use rust_web_server::app::App;
use rust_web_server::auth::BasicAuthLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(BasicAuthLayer::from_htpasswd_file(".htpasswd").expect("failed to read htpasswd file"));
```
Each non-empty, non-comment (`#`-prefixed) line is `username:credential`, where `credential` is one of:
- a plain-text password (Apache's `htpasswd -p` format), or
- `{SHA256}` followed by the base64-encoded SHA-256 digest of the password — **rws's own scheme**, not Apache's.
```
# .htpasswd
alice:s3cret
bob:{SHA256}9S+9MrKzuG/4jvbEkGKChfSCrxXdyylUH5S89Saj9sc=
```
Generate a `{SHA256}` entry with `openssl` (no need to write Rust code):
```bash
printf '%s' 'hunter2' | openssl dgst -sha256 -binary | openssl base64
# -> 9S+9MrKzuG/4jvbEkGKChfSCrxXdyylUH5S89Saj9sc=
```
:::caution[Not compatible with real Apache htpasswd files]
Apache's actual `{SHA}` scheme is SHA-1 (not SHA-256), and modern `htpasswd` tool versions default to bcrypt or `$apr1$` (iterated MD5) when no flag is given. None of `{SHA}`, `$apr1$`, or bcrypt are supported here — this crate has no third-party crypto dependencies beyond the audited RustCrypto hash crates it already uses (`hmac`, `sha2`), and hand-rolling SHA-1, MD5, or bcrypt from scratch isn't a risk worth taking for an authentication check. A real Apache-generated htpasswd file will **not** verify against `from_htpasswd_file`.
If you need genuine Apache-hash compatibility, use [`BasicAuthLayer::new`](#http-basic-auth) with your own closure backed by the `bcrypt`/`sha1` crate of your choice, or regenerate the file with `htpasswd -p` (plain text) or the `{SHA256}` scheme above.
:::
## JWT — HS256
`JwtLayer` verifies `Authorization: Bearer <token>` JWTs signed with HMAC-SHA256.
```rust
use rust_web_server::app::App;
use rust_web_server::auth::JwtLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(JwtLayer::new(b"my-signing-secret"));
```
Tokens with a past `exp` claim are rejected. Any token that fails format validation, algorithm check, or signature verification returns `401 Unauthorized`.
### Building tokens
Use `build_jwt` in a login handler to issue a signed token:
```rust
use rust_web_server::auth::build_jwt;
fn login_handler(/* ... */) -> String {
let claims = r#"{"sub":"42","role":"admin","exp":9999999999}"#;
let token = build_jwt(claims, b"my-signing-secret");
// return token to client
token
}
```
`build_jwt(claims_json, secret)` always produces an HS256 JWT with the `alg` and `typ` header set. The `claims_json` argument is any valid JSON object string.
### Verifying tokens and reading claims
Call `verify_jwt` directly inside a handler when you also need the decoded claims. The verification is cheap (~1 µs) so double-calling is fine.
```rust
use rust_web_server::auth::{verify_jwt, extract_bearer_token};
use rust_web_server::request::Request;
use rust_web_server::response::Response;
fn protected_handler(req: &Request) -> Response {
let token = extract_bearer_token(req).expect("JwtLayer already validated this");
let claims = verify_jwt(&token, b"my-signing-secret").unwrap();
println!("subject: {:?}", claims.sub);
println!("expires: {:?}", claims.exp);
// parse custom claims from claims.raw (raw JSON string)
Response::new()
}
```
### `Claims` struct
| Field | Type | Description |
|---|---|---|
| `sub` | `Option` | Subject (user ID), if present |
| `exp` | `Option<u64>` | Expiration as Unix seconds, if present |
| `raw` | `String` | Raw UTF-8 JSON payload — inspect for custom claims |
`claims.is_valid_at(now_secs)` returns `true` when the token is not yet expired. Returns `true` when `exp` is absent (no expiry set).
### `extract_bearer_token`
Extracts the raw token string from `Authorization: Bearer <token>`. Returns `None` when the header is absent or does not start with `Bearer `.
```rust
use rust_web_server::auth::extract_bearer_token;
if let Some(token) = extract_bearer_token(&request) {
// token is the raw JWT string
}
```
## Login handler pattern
A typical login flow: verify credentials, issue a JWT, return it to the client.
```rust
use rust_web_server::auth::build_jwt;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::body::form_urlencoded::FormUrlEncoded;
use std::time::{SystemTime, UNIX_EPOCH};
fn login(req: &Request) -> Response {
let form = FormUrlEncoded::parse(req.body.to_vec()).unwrap_or_default();
let username = form.get("username").map(String::as_str).unwrap_or("");
let password = form.get("password").map(String::as_str).unwrap_or("");
if username != "admin" || password != "s3cret" {
let mut r = Response::new();
r.status_code = 401;
return r;
}
let exp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() + 3600; // 1 hour
let claims = format!(r#"{{"sub":"{username}","exp":{exp}}}"#);
let token = build_jwt(&claims, b"my-signing-secret");
let mut r = Response::new();
// set body to JSON with token
r
}
```
:::note[Algorithm support]
`build_jwt` and `verify_jwt` only support HS256. For RS256/ES256 against a static public key, see the next section. For public-key JWTs from an identity provider that rotates keys behind a live JWKS endpoint, use the `sso` feature's `JwksCache` instead.
:::
## JWT — RS256 / ES256 (`auth-asymmetric` feature)
A common pattern: a separate auth server issues JWTs signed with its RSA or P-256 *private* key, and your service only ever needs the corresponding *public* key to verify them — no OAuth login flow, no JWKS endpoint, no key rotation to track. `JwtLayer::rs256` and `JwtLayer::es256` cover exactly this, without pulling in the full `sso` feature (OIDC login/callback handlers, PKCE, JWKS fetching):
```toml
[dependencies]
rust-web-server = { version = "18", features = ["auth-asymmetric"] }
```
```rust
use rust_web_server::app::App;
use rust_web_server::auth::JwtLayer;
use rust_web_server::core::New;
// public_key_pem is SubjectPublicKeyInfo PEM (RS256 also accepts PKCS#1 `BEGIN RSA PUBLIC KEY`), e.g.:
// openssl rsa -in key.pem -pubout (RS256)
// openssl ec -in key.pem -pubout (ES256)
let app = App::new().wrap(JwtLayer::rs256(&rsa_public_key_pem).expect("invalid RSA public key"));
// or:
let app = App::new().wrap(JwtLayer::es256(&ec_public_key_pem).expect("invalid P-256 public key"));
```
Both reject a token whose header `alg` doesn't actually match what was verified — an RS256 key can't wave through a token whose header claims `"alg":"HS256"` just because the bytes happen to parse as something. ES256 signatures are validated in the raw 64-byte `r || s` form JWTs specify, not the ASN.1 DER encoding OpenSSL produces by default when signing outside of a JWT library.
If a handler also needs the decoded claims, call the standalone verify functions directly — same code `JwtLayer` uses internally:
```rust
use rust_web_server::auth::{verify_jwt_rs256, verify_jwt_es256};
use rust_web_server::rsa_key::RsaPublicKey;
let rsa_public_key = RsaPublicKey::from_public_key_pem(PEM)?; // or from_components(n, e)
let claims = verify_jwt_rs256(&token, &rsa_public_key).unwrap();
let claims = verify_jwt_es256(&token, &ec_public_key).unwrap();
```
RSA keys are 2048–8192 bits. Since 18.0.0 RSA verification is implemented
in-crate (`rust_web_server::rsa_key`) rather than with the `rsa` crate, whose
RUSTSEC-2023-0071 advisory has no fixed release; `verify_jwt_rs256` takes an
`rsa_key::RsaPublicKey`.
`RsaPublicKey` loads a key with `from_public_key_pem` (`BEGIN PUBLIC KEY` or
`BEGIN RSA PUBLIC KEY`), `from_public_key_der` (SPKI), `from_pkcs1_der`, or
`from_components(n, e)` (a JWK's `n`/`e`). It also verifies any
RSASSA-PKCS1-v1_5 / SHA-256 signature directly, not just a JWT:
```rust
use rust_web_server::rsa_key::RsaPublicKey;
let key = RsaPublicKey::from_public_key_pem(&std::fs::read_to_string("issuer.pub.pem")?)?;
let ok: bool = key.verify_pkcs1v15_sha256(b"message", &signature);
```
:::note[Upgrading from 17.x]
`verify_jwt_rs256` and `SamlIdpMetadata.signing_key` used `rsa::RsaPublicKey`.
Build an `rsa_key::RsaPublicKey` instead (`from_public_key_pem`,
`from_public_key_der`, or `from_components(n, e)`).
:::
:::note[When to use `sso::JwksCache` instead]
`JwtLayer::rs256`/`::es256` pin a single static public key at construction time. Use `sso::JwksCache` when the signing key can rotate and you need to fetch current keys from a live JWKS URL (`.well-known/jwks.json`), keyed by `kid` — the right fit for a full OIDC identity provider, not a service-to-service integration with one known signer.
:::
## Forward-auth (delegate to an external service)
`ForwardAuthLayer` delegates the allow/deny decision to an external HTTP service — the same pattern as Traefik's `forwardAuth` or nginx's `auth_request`. Use it to gate requests behind a centralized policy engine (OPA, Casbin) or a shared SSO/session service, without embedding that logic in your app.
```rust
use rust_web_server::app::App;
use rust_web_server::auth::forward::ForwardAuthLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(ForwardAuthLayer::new("http://auth.internal/verify")
.copy_header("X-User-Id")
.copy_header("X-Roles")
.timeout_ms(2000));
```
On every request, the layer sends a `GET` to the configured URL with every incoming request header copied onto it (so the auth service can inspect a session cookie or an existing `Authorization` header):
1. **`2xx`** — the request proceeds to the next layer/handler. Any header named via `.copy_header(name)` that's present on the *auth service's response* **replaces** the same-named header on the forwarded request. This is deliberate: if it merely appended, a client could send its own `X-User-Id` and have it coexist with the trusted value.
2. **Any other status** — the auth service's response is returned to the client **verbatim**: status code, headers (minus hop-by-hop and body-framing ones), and body. This preserves a `WWW-Authenticate` challenge or a `Location` redirect into an OAuth login flow without `rws` needing to understand either.
3. **Auth service unreachable** (connection refused, timeout, DNS failure) — `502 Bad Gateway`. This fails *closed*: an unreachable auth service is never treated as "access granted."
Redirects from the auth service itself are not followed (`max_redirects(0)` internally) so a `3xx` response reaches step 2 intact instead of being silently resolved to whatever it points to.
No new Cargo dependency — `ForwardAuthLayer` reuses the existing `crate::http_client::Client`, the same synchronous outbound HTTP client used elsewhere in the framework.
:::note[Builder options]
| Method | Default | Purpose |
|---|---|---|
| `ForwardAuthLayer::new(url)` | — | Auth service URL, e.g. `"http://auth.internal/verify"` |
| `.copy_header(name)` | none | Copy `name` from the auth response onto the forwarded request on `2xx`; call multiple times for multiple headers |
| `.timeout_ms(ms)` | `5000` | Auth service call timeout |
:::
## No-code auth in the config-driven proxy
`rws.config.toml`'s `[route.middleware.auth]` wires directly into `JwtLayer` and `BasicAuthLayer` — no Rust code required. Both need the `auth` feature enabled at build time (`cargo build --features auth`; the default build doesn't include it).
```toml
[[route]]
name = "api"
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
[route.middleware.auth]
type = "jwt"
secret_env = "JWT_SECRET"
```
```toml
[route.middleware.auth]
type = "basic"
htpasswd_file = ".htpasswd"
```
See [Config-Driven Proxy](/proxy/config-driven/) for the full `[route.middleware]` reference. Auth config fails closed: an unset/empty `token_env`/`secret_env`, a missing `htpasswd_file`, or a build without the `auth` feature makes that route answer `503 Service Unavailable` (with an error in the startup log) instead of serving it unprotected. Other routes keep working. Bearer tokens are compared in constant time.
## Password hashing (`crypto` feature)
The `crypto` feature (independent of `auth`) adds Argon2id password hashing and secure random tokens:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["crypto"] }
```
```rust
use rust_web_server::crypto::{hash_password, verify_password, generate_token};
// At registration: store the returned PHC string ($argon2id$v=19$...) as-is.
// The random salt is embedded in it, so no separate salt column is needed.
let hash = hash_password("hunter2")?;
// At login: constant-time comparison; Err only when `hash` isn't a valid PHC string.
if !verify_password(&submitted_password, &hash)? {
// return 401
}
// Password-reset tokens, email verification codes, API keys:
let reset_token = generate_token(32); // 32 random bytes -> 64-char lowercase hex
```
All three live in `rust_web_server::crypto`; errors are `CryptoError`. Use at least 16 bytes for tokens and 32 bytes for high-value secrets such as API keys.
---
# OAuth2 / OIDC SSO
<https://rws8.tech/features/sso/>
The `sso` feature adds full OAuth 2.0 / OIDC support: authorization-code + PKCE flow, session management, and asymmetric JWT verification via a live JWKS endpoint. The `sso-server` feature additionally lets `rws` act as its own OAuth 2.0 Authorization Server, issuing tokens instead of delegating to an external IdP.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["sso"] }
```
## Quick start
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::session::SessionStore;
use rust_web_server::sso::{OidcAuth, OidcConfig};
let sessions = Arc::new(SessionStore::new(86_400)); // 24 h TTL
let config = OidcConfig::google(
"my-client-id",
"my-client-secret",
"https://example.com/auth/callback",
);
let app = App::new().wrap(OidcAuth::new(config, sessions));
```
## Loading config from environment variables
`OidcConfig::from_env()` reads environment variables so credentials stay out of source code:
```bash
RWS_OIDC_PROVIDER=google # google | github | microsoft | okta | auth0 | keycloak | custom
RWS_OIDC_CLIENT_ID=my-client-id
RWS_OIDC_CLIENT_SECRET=my-secret
RWS_OIDC_REDIRECT_URI=https://example.com/auth/callback
RWS_OIDC_SCOPES=openid email profile # space-separated; default: openid email profile
RWS_OIDC_POST_LOGIN_REDIRECT=/dashboard
```
Provider-specific extras:
| Provider | Extra variable | Meaning |
|---|---|---|
| `microsoft` | `RWS_OIDC_TENANT_ID` | Tenant GUID, `common`, `organizations`, or `consumers` |
| `okta` | `RWS_OIDC_ISSUER` | Okta org domain, e.g. `dev-12345.okta.com` |
| `auth0` | `RWS_OIDC_ISSUER` | Auth0 domain, e.g. `myapp.us.auth0.com` |
| `keycloak` | `RWS_OIDC_ISSUER` + `RWS_OIDC_TENANT_ID` | Base URL + realm name |
| `custom` | `RWS_OIDC_ISSUER` | OIDC issuer URL for auto-discovery |
```rust
let config = OidcConfig::from_env().expect("OIDC config missing");
```
## Provider presets
All presets accept `(client_id, client_secret, redirect_uri)` unless noted.
```rust
// Google
let cfg = OidcConfig::google("id", "secret", "https://example.com/auth/callback");
// Microsoft Entra ID / Azure AD
let cfg = OidcConfig::microsoft("common", "id", "secret", "https://example.com/auth/callback");
// GitHub OAuth 2.0 (no id_token — uses UserInfo endpoint instead)
let cfg = OidcConfig::github("id", "secret", "https://example.com/auth/callback");
// Okta
let cfg = OidcConfig::okta("dev-12345.okta.com", "id", "secret", "https://example.com/auth/callback");
// Auth0
let cfg = OidcConfig::auth0("myapp.us.auth0.com", "id", "secret", "https://example.com/auth/callback");
// Keycloak
let cfg = OidcConfig::keycloak(
"https://keycloak.example.com",
"my-realm",
"id", "secret",
"https://example.com/auth/callback",
);
// Auto-discovery from any OIDC-compliant provider
let cfg = OidcConfig::discover("https://provider.example.com", "id", "secret", "https://example.com/auth/callback")?;
```
Chain `.scopes([...])` or `.post_login_redirect(path)` to override the defaults (`openid email profile` — `read:user user:email` for GitHub — and `/`).
## Authorization-code + PKCE flow
```
Browser App Identity Provider
| | |
|-- GET /any-page --> | |
| | (no session) |
|<-- 302 /auth/login--| |
| | |
|-- GET /auth/login ->| |
| | generate PKCE verifier+challenge, |
| | state, nonce → save in session |
|<-- 302 idp/authorize| |
| |
|------- GET idp/authorize?code_challenge=... ----------->|
|<--------------------- 302 /auth/callback?code=... ------|
| |
|-- GET /auth/callback?code=... -->| |
| | exchange code (with PKCE verifier)|
| |<-------- POST idp/token ----------|
| |----------- id_token+access_token->|
| | verify id_token via JWKS |
| | store OidcClaims in session |
|<-- 302 /dashboard --| |
```
`OidcAuth` intercepts three paths automatically:
| Path | Action |
|---|---|
| `GET /auth/login` | Generates PKCE + state + nonce, redirects to provider |
| `GET /auth/callback` | Validates state, exchanges code, verifies id_token, stores claims |
| `GET /auth/logout` | Destroys session, redirects to `/` |
All other paths check the session. Authenticated requests have claims injected into the `X-Rws-Oidc-Claims` header (JSON) and are forwarded to the next layer. Unauthenticated requests redirect to `/auth/login?return_to=<current-path>`.
## Claims extraction in handlers
```rust
use rust_web_server::sso::OidcAuth;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
fn dashboard(req: &Request) -> Response {
// Read the full claims object
if let Some(claims) = OidcAuth::claims(req) {
let user_id = &claims.sub;
let email = claims.email.as_deref().unwrap_or("unknown");
let name = claims.name.as_deref().unwrap_or("unknown");
println!("User {user_id} ({email}) / {name}");
}
// Shortcuts
let sub = OidcAuth::sub(req);
let email = OidcAuth::email(req);
Response::new()
}
```
### `OidcClaims` fields
| Field | Type | Description |
|---|---|---|
| `sub` | `String` | Subject (unique user ID at the provider) |
| `iss` | `String` | Issuer URL |
| `aud` | `Vec` | Intended audience (your client ID) |
| `exp` | `u64` | Expiration as Unix seconds |
| `iat` | `u64` | Issued-at as Unix seconds |
| `nonce` | `Option` | Nonce for replay protection |
| `email` | `Option` | User's email address |
| `email_verified` | `Option<bool>` | Whether the email is verified |
| `name` | `Option` | Full display name |
| `given_name` | `Option` | First name |
| `family_name` | `Option` | Last name |
| `picture` | `Option` | Profile picture URL |
| `locale` | `Option` | User's locale |
## Public path exclusion
Exclude paths from the authentication check (health endpoints, public assets, etc.):
```rust
let app = App::new()
.wrap(
OidcAuth::new(config, sessions)
.exclude("/healthz")
.exclude("/public/")
);
```
## Custom paths
```rust
OidcAuth::new(config, sessions)
.login_path("/login")
.callback_path("/oauth/callback")
.logout_path("/logout")
```
## RS256/ES256 JWT verification via JWKS
`JwksCache` fetches the provider's public keys from the JWKS URI and verifies `id_token` JWTs. Key rotation is handled automatically: on a verification failure the cache is refreshed and the verification is retried once.
```rust
use rust_web_server::sso::{JwksCache, VerifyOptions};
let jwks = JwksCache::new("https://accounts.google.com/.well-known/jwks");
let opts = VerifyOptions {
audience: "my-client-id",
issuer: "https://accounts.google.com",
leeway_secs: 60,
};
let claims = jwks.verify_jwt(&id_token, &opts)?;
```
`OidcAuth` calls `JwksCache::verify_jwt` internally; you only need to use it directly when processing tokens outside the middleware (e.g. in a mobile API that receives tokens from a separate auth service).
:::note[GitHub does not issue id_tokens]
GitHub's OAuth 2.0 implementation does not return an `id_token`. `OidcAuth` detects this and falls back to the UserInfo endpoint to fetch claims.
:::
## The outbound HTTP client behind it all
Token exchange, JWKS fetch, discovery, and UserInfo calls are all plain
[`http_client::Client`](/reference/api/) requests — the same synchronous,
dependency-free HTTP/1.1 + TLS client used everywhere else in `rws`. There is
no separate "SSO HTTP client"; `sso` just depends on the `http-client`
feature (implied automatically) for HTTPS support.
The token endpoint expects an `application/x-www-form-urlencoded` body, so
`OidcClient::exchange_code` uses `Client`'s `.form()` builder method:
```rust
use rust_web_server::http_client::Client;
let resp = Client::new()
.post("https://oauth2.googleapis.com/token")
.form(&[
("grant_type", "authorization_code"),
("code", "abc123"),
("redirect_uri", "https://example.com/auth/callback"),
("client_id", "my-client-id"),
("client_secret", "my-client-secret"),
])
.send()?;
```
`.form()` percent-encodes each pair, joins them with `&`, and sets
`Content-Type: application/x-www-form-urlencoded` — available on both the
sync `RequestBuilder` and the async `AsyncRequestBuilder` (`http2` feature).
## `rws` as its own OAuth 2.0 Authorization Server
Everything above covers `rws` as an OAuth 2.0 / OIDC **client**, delegating
login to an external identity provider. The `sso-server` feature (implies
`sso` and `auth`) flips the role: `AuthServer` lets `rws` issue its own
short-lived JWTs to downstream services or single-page apps, instead of
always delegating to Google/Okta/etc.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["sso-server"] }
```
```rust
use std::sync::Arc;
use std::time::Duration;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::session::SessionStore;
use rust_web_server::sso::server::{AuthServer, AuthServerConfig};
use rust_web_server::sso::client_store::{ClientStore, OAuthClient, GrantType};
let auth_server = AuthServer::new(AuthServerConfig {
issuer: "https://myapp.com".into(),
signing_secret: std::env::var("RWS_AUTH_SIGNING_SECRET").unwrap(),
access_token_ttl: Duration::from_secs(3600),
refresh_token_ttl: Duration::from_secs(86_400 * 30),
clients: ClientStore::new()
.add(OAuthClient {
client_id: "spa-frontend".into(),
client_secret: None, // public client, uses PKCE
redirect_uris: vec!["https://spa.example.com/callback".into()],
grants: vec![GrantType::AuthorizationCode],
scopes: vec!["openid".into(), "email".into()],
})
.add(OAuthClient {
client_id: "backend-service".into(),
client_secret: Some("s3cr3t".into()),
redirect_uris: vec![],
grants: vec![GrantType::ClientCredentials],
scopes: vec!["api:read".into(), "api:write".into()],
}),
sessions: Arc::new(SessionStore::new(86_400)),
});
let app = App::new().wrap(auth_server);
```
`AuthServer` intercepts four paths and passes everything else through:
| Path | Method | Purpose |
|---|---|---|
| `/oauth/token` | `POST` | Issues tokens for the `client_credentials`, `authorization_code` (+ PKCE), and `refresh_token` grants |
| `/oauth/authorize` | `GET` | Starts the authorization-code flow for an end user |
| `/.well-known/openid-configuration` | `GET` | Discovery document |
| `/.well-known/jwks.json` | `GET` | Always `{"keys":[]}` — see below |
A machine-to-machine client calls the token endpoint directly:
```
POST /oauth/token
grant_type=client_credentials&client_id=backend-service&client_secret=s3cr3t
→ {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}
```
:::caution[Two deviations from a textbook Authorization Server]
- **Tokens are signed HS256**, not RSA/EC from a PEM private key — this
crate has no PEM/DER private-key parser, and HS256 is a fully adequate
choice when the issuer and every resource server share one secret.
`GET /.well-known/jwks.json` therefore always returns an empty key set —
there's no public key to publish for a symmetric algorithm. Configure
resource servers with the same `signing_secret` directly, e.g.
`auth::JwtLayer::new(secret)` or `auth::verify_jwt(token, secret)`.
- **`/oauth/authorize` has no built-in login page.** It trusts an existing
application session: if the browser's session (default cookie, default
key `"user_id"`, override with `.subject_session_key()`) already
identifies a user, a code is minted immediately and the browser is
redirected straight to `redirect_uri?code=...`; otherwise it's redirected
to a configurable `login_url` (default `/login`) with
`?return_to=<original authorize URL>`. Building that login page — and
populating the session after a successful login — is the embedding
application's own responsibility, the same boundary `OidcAuth` draws
around needing an *external* IdP to already exist.
:::
`ClientStore` has no `::from_env()` — a list of clients with per-client
secrets has no natural flat-env-var encoding (unlike `OidcConfig`'s
one-client-per-process shape above). `ClientStore::new().add(...)` is the
only registration path; load clients from wherever your application already
keeps them (config file, database, secret manager) and build the store once
at startup.
---
# SAML 2.0 SSO
<https://rws8.tech/features/saml/>
The `sso-saml` feature (implies `sso`) adds `SamlSp`, a SAML 2.0 Service Provider middleware — the enterprise/B2B alternative to [OAuth2 / OIDC SSO](/features/sso/) for identity providers that only speak SAML (Active Directory Federation Services, Okta SAML, Google Workspace SAML, Keycloak).
```toml
[dependencies]
rust-web-server = { version = "18", features = ["sso-saml"] }
```
## Quick start
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::session::SessionStore;
use rust_web_server::sso::saml::{SamlSp, SamlConfig, SamlIdpMetadata, AttributeMap};
let saml_sp = SamlSp::new(SamlConfig {
sp_entity_id: "https://myapp.com/saml/metadata".into(),
sp_acs_url: "https://myapp.com/saml/acs".into(),
idp_metadata: SamlIdpMetadata::from_file("idp-metadata.xml")?,
sessions: Arc::new(SessionStore::new(86_400)),
})
.attribute_map(
AttributeMap::new()
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name"),
);
let app = App::new().wrap(saml_sp);
```
`SamlSp` intercepts four paths and passes everything else through:
| Path | Method | Purpose |
|---|---|---|
| `/saml/metadata` | `GET` | SP metadata XML — give this URL to the IdP |
| `/saml/login` | `GET` | Auto-submitting HTML form POSTing an `AuthnRequest` to the IdP |
| `/saml/acs` | `POST` | Assertion Consumer Service — receives and verifies the IdP's `Response` |
| `/saml/logout` | `GET` | Destroys the local session and redirects home |
Any other path checks the session: if a completed login exists, claims are injected as the `X-Rws-Saml-Claims` header (JSON) and the request proceeds; otherwise the browser is redirected to `/saml/login?return_to=<original path>`.
The paths can be changed with `.metadata_path()`, `.login_path()`, `.acs_path()` and `.logout_path()`; `.post_login_redirect(path)` sets where a login without `return_to` lands (default `/`).
## Reading claims in a handler
```rust
use rust_web_server::sso::saml::SamlSp;
use rust_web_server::request::Request;
fn dashboard(req: &Request) {
let name_id = SamlSp::name_id(req).unwrap(); // Subject/NameID
let email = SamlSp::attr(req, "email"); // mapped via AttributeMap
let claims = SamlSp::claims(req).unwrap(); // full SamlClaims { name_id, attributes }
}
```
## Loading IdP metadata
```rust
use rust_web_server::sso::saml::SamlIdpMetadata;
// From a downloaded/exported metadata file:
let meta = SamlIdpMetadata::from_file("idp-metadata.xml")?;
// Or fetch it directly from the IdP's metadata URL:
let meta = SamlIdpMetadata::from_url("https://idp.corp.com/metadata")?;
// Or from an XML string you already have:
let meta = SamlIdpMetadata::from_str(&xml)?;
```
All three parse the same shape: `entityID`, the `SingleSignOnService` endpoint (an `HTTP-POST` binding entry is preferred if the metadata advertises one — see below for why), and the signing certificate under `KeyDescriptor[use=signing]`.
## What assertion validation checks
Every `POST /saml/acs` request runs the full set the SAML spec calls for:
- XML signature verification (`RSA-SHA256` only) over the `Assertion`
- Exactly one `Assertion` in the response (protects against signature-wrapping attacks — see below)
- `Issuer` matches the configured IdP entity ID
- `Conditions/@NotBefore` / `@NotOnOrAfter` time window (60s leeway)
- `AudienceRestriction/Audience` matches the SP's own entity ID
- `InResponseTo` matches the `AuthnRequest` ID stored at `/saml/login` time (anti-replay)
- `SubjectConfirmation/@Method` is `urn:oasis:names:tc:SAML:2.0:cm:bearer`
- `SubjectConfirmationData/@Recipient` matches the configured ACS URL
## Attribute mapping
SAML attributes have IdP-specific names — often long URIs. `AttributeMap` translates them into the field names your application actually wants to read:
```rust
use rust_web_server::sso::saml::AttributeMap;
let map = AttributeMap::new()
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "email")
.map("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name")
.map("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", "groups");
```
An attribute with no mapping entry is simply not exposed under a friendly name — read it via its raw SAML name from `SamlClaims::attributes` directly if needed. Multi-valued SAML attributes keep only their first value.
:::note[Why `SamlClaims`, not `OidcClaims`?]
The design for this feature originally sketched mapping SAML attributes into the same [`OidcClaims`](/features/sso/) shape `OidcAuth` uses — but `OidcClaims` was deliberately built (see the OIDC SSO page) without a `groups` field, and this feature's own attribute-mapping example maps exactly a `groups` attribute. Reusing `OidcClaims` would silently drop it. `SamlClaims { name_id, attributes: HashMap }` is a plain, free-form map instead — a better fit for SAML's attribute model anyway.
:::
## Scope and deviations from a full SAML implementation
SAML 2.0 is a large, XML-heavy spec. This implementation covers the common enterprise SP flow — receiving and verifying IdP-initiated or SP-initiated bearer assertions — and makes several deliberate, documented scope decisions rather than attempting full spec coverage:
:::caution[Signature verification is byte-exact, not full XML canonicalization]
Correct XML-DSig verification canonicalizes (C14N) the signed XML before checking digests/signatures, so reformatting whitespace or attribute order doesn't invalidate an otherwise-unchanged signature. This module verifies against the **literal bytes as transmitted** instead of implementing C14N (a large undertaking prone to subtle bugs). The practical effect is **fail-closed, not fail-open**: an IdP that reformats XML between signing and transmission would have legitimate logins rejected (loud, immediate, fixable) — never would a forged assertion be accepted. No mainstream IdP (Okta, Azure AD, Google Workspace, Keycloak) reformats between signing and transmission in this flow.
:::
- **No `quick-xml` dependency.** A small, purpose-built XML parser is hand-rolled instead, consistent with this crate's existing philosophy of not taking dependencies for formats it can parse itself (JSON, HTTP, form-urlencoded are all hand-rolled too). It never processes `<!DOCTYPE>` — rejected outright, eliminating XXE by never implementing entity resolution at all.
- **`RSA-SHA256` only** — no SHA-1 (deprecated), no EC-signed assertions.
- **`EncryptedAssertion` is rejected outright**, not silently ignored — no XML decryption is implemented.
- **`AuthnRequest`s use the HTTP-POST binding**, not HTTP-Redirect (which requires DEFLATE compression this crate has no dependency for), and are **never signed** — most IdPs accept unsigned `AuthnRequest`s by default; the flow's security rests on the IdP-signed *assertion*, which is fully verified.
- **Logout is local-only** — `/saml/logout` destroys the SP's own session; there is no SP-initiated `LogoutRequest`/`LogoutResponse` exchange with the IdP. This mirrors [`OidcAuth`'s own logout](/features/sso/), which draws the identical boundary.
None of these are silent gaps — each one either fails closed (rejects rather than accepts something suspicious) or is a compatibility boundary (an unsupported binding/algorithm), never a case where something insecure is accepted as valid.
---
# CSRF Protection
<https://rws8.tech/features/csrf/>
The `csrf` feature adds `CsrfLayer` middleware that protects state-mutating requests against cross-site request forgery using the double-submit cookie pattern.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["csrf"] }
```
## Quick start
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::csrf::CsrfLayer;
let app = App::new().wrap(CsrfLayer::new());
```
## How it works
1. **Safe methods (`GET`, `HEAD`, `OPTIONS`, `TRACE`):** `CsrfLayer` reads the existing `_csrf` cookie or generates a new 32-byte random token (hex-encoded, 64 characters). The token is injected into the request as a private `X-Rws-Csrf-Token` header so `CsrfToken::from_request` can return it to the handler. The `_csrf` cookie is set (or refreshed) on the response with `SameSite=Strict; Path=/`.
2. **Mutating methods (`POST`, `PUT`, `PATCH`, `DELETE`):** The layer reads the cookie value and the submitted value (from the `X-CSRF-Token` request header or the `_csrf` form field in `application/x-www-form-urlencoded` bodies). The two values are compared in **constant time** to prevent timing attacks. If they do not match, `403 Forbidden` is returned immediately.
## Embedding the token in HTML forms
In a `GET` handler, use `CsrfToken::from_request` to retrieve the current token:
```rust
use rust_web_server::csrf::CsrfToken;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
fn show_form(req: &Request, _conn: &ConnectionInfo) -> Response {
let token = CsrfToken::from_request(req)
.map(|t| t.value().to_string())
.unwrap_or_default();
let html = format!(
r#"<form method="POST" action="/submit">
<input type="hidden" name="_csrf" value="{token}">
<button type="submit">Submit</button>
</form>"#
);
// build Response with `html` body ...
Response::new()
}
```
`CsrfToken::from_request` returns `None` when `CsrfLayer` is not in the middleware stack.
## AJAX / fetch requests
For JavaScript clients, read the token from the `_csrf` cookie (the cookie is not `HttpOnly` by default) and send it as the `X-CSRF-Token` header:
```javascript
// Read the cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Attach to every mutating request
fetch('/api/resource', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('_csrf'),
},
body: JSON.stringify({ key: 'value' }),
});
```
## React (or any fetch/axios-based SPA)
The pattern above applies directly to a React app — the wrinkle is *when* the `_csrf` cookie gets set, since a single-page app has no traditional server-rendered `GET` page load for every navigation the way a template-rendered site does.
**In production**, `rws` itself serves the built app (see [Static Files & SPA Fallback](/features/static-files/)) — so the very first request the browser makes (loading `index.html`) is already a `GET` through `CsrfLayer`, which sets the `_csrf` cookie on that response before any React code has even run. By the time your app mounts, the cookie already exists.
**In local dev**, if you're using the [recommended frontend dev-server proxy setup](/getting-started/frontend-dev-proxy/), the HTML shell comes from Vite/webpack-dev-server, not `rws` — so nothing sets the `_csrf` cookie until the SPA makes its first request through the proxy to `rws`. If that first request happens to be a mutating one (e.g. a login `POST`), there's no cookie yet and it gets `403`. Prime it explicitly with a safe `GET` call on app startup:
```jsx
// api.js
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
export const api = axios.create({ baseURL: '/api' });
// Attach the CSRF token to every mutating request. GET/HEAD requests are
// never checked, so they're left alone.
api.interceptors.request.use((config) => {
if (['post', 'put', 'patch', 'delete'].includes(config.method)) {
config.headers['X-CSRF-Token'] = getCookie('_csrf');
}
return config;
});
```
```jsx
// App.jsx
function App() {
useEffect(() => {
// Prime the _csrf cookie once on app startup — any safe (GET) endpoint
// works, since CsrfLayer refreshes the cookie on every GET regardless
// of which route handles it.
api.get('/session');
}, []);
// ... rest of the app
}
```
After this runs once, `getCookie('_csrf')` inside the interceptor has a real value for every subsequent mutating request — login forms, form submissions, `DELETE` buttons, all get the header attached automatically with no per-call boilerplate.
Whichever approach you use, the `_csrf` cookie must stay readable from JavaScript — don't call `.http_only(true)` on the `CsrfLayer` (see [Defaults and customization](#defaults-and-customization) below), since that's the opposite, HTML-form-only workflow where JS never needs to see the token at all.
## Defaults and customization
| Setting | Default | Builder method |
|---|---|---|
| Cookie name | `_csrf` | `.cookie_name("csrf_token")` |
| Form field name | `_csrf` | `.field_name("csrf_token")` |
| Header name | `X-CSRF-Token` | `.header_name("X-My-CSRF")` |
| `SameSite` | `Strict` | (not configurable) |
| `HttpOnly` | `false` | `.http_only(true)` |
| `Secure` | `false` | `.secure(true)` |
```rust
use rust_web_server::csrf::CsrfLayer;
let layer = CsrfLayer::new()
.cookie_name("csrf_token")
.field_name("csrf_token")
.http_only(true) // HTML-form-only workflow; JS cannot read the cookie
.secure(true); // Required in production (HTTPS only)
```
:::note[Production checklist]
In production, always set `.secure(true)` so the cookie is only transmitted over HTTPS. This prevents the CSRF token from leaking over plain HTTP.
:::
## What is and is not checked
| Method | Checked? |
|---|---|
| `GET` | No — token is generated/refreshed instead |
| `HEAD` | No |
| `OPTIONS` | No |
| `TRACE` | No |
| `POST` | Yes |
| `PUT` | Yes |
| `PATCH` | Yes |
| `DELETE` | Yes |
## Constant-time comparison
The middleware uses a constant-time byte comparison to prevent timing attacks:
- The comparison always runs in time proportional to the token length, regardless of where the mismatch occurs.
- Length mismatch returns `false` immediately (lengths are not secret).
- The token is 64 hex characters (256 bits of entropy from the OS CSPRNG via `OsRng`).
## Token source precedence
On mutating requests, the submitted token is extracted in this order:
1. `X-CSRF-Token` request header (any content type).
2. `_csrf` field in an `application/x-www-form-urlencoded` request body.
If neither is present, `403 Forbidden` is returned.
---
# Sessions
<https://rws8.tech/features/sessions/>
`rust-web-server` ships three session store implementations in `src/session/mod.rs`:
| Store | Persistence | Scales horizontally |
|-------|-------------|---------------------|
| `SessionStore` | In-process `HashMap`; lost on restart | No |
| `DbSessionStore` | Relational database via model layer | Yes (shared DB) |
| `RedisSessionStore` | Redis server | Yes |
All three expose the same conceptual API: **create → set data → save → load → destroy**. `SessionStore` methods return values directly; `DbSessionStore` and `RedisSessionStore` return `Result` because they perform I/O.
## SessionStore (in-memory)
Sessions are stored in an in-memory, thread-safe map with automatic TTL-based expiry. The store is designed to live inside your application state so every handler shares the same session map without any global state.
## Session store
### Creating a store
```rust
use rust_web_server::session::SessionStore;
// Sessions expire after 3600 seconds (1 hour).
let store = SessionStore::new(3600);
```
`SessionStore` is cheaply `Clone`-able — all clones share the same backing map via `Arc`. Place one instance in your application state.
### Creating a session
```rust
// Allocates a new session with a generated ID and inserts it into the store.
let mut session = store.create();
```
For public-facing services that need cryptographically unpredictable IDs, supply your own ID instead:
```rust
let id = my_csprng_hex_id(); // e.g. from `ring` or `getrandom`
let mut session = store.create_with_id(id);
```
:::note[ID generation]
The default `create()` uses a non-cryptographic splitmix64 finalizer seeded from the system clock and an atomic counter. It is sufficient for most internal applications. Replace it with a CSPRNG for public deployments.
:::
### Reading and writing session data
`Session` holds key/value pairs as strings. Mutate the session locally, then call `save` to persist changes.
```rust
session.set("user_id", "42");
session.set("role", "admin");
let user_id: Option<&str> = session.get("user_id"); // Some("42")
let missing: Option<&str> = session.get("absent"); // None
session.remove("role");
let has_role: bool = session.contains("role"); // false
// Persist changes back to the store.
store.save(&session);
```
### Loading a session
```rust
// Returns None if the session is unknown or expired.
let session = store.load(&session_id);
```
### Destroying a session
```rust
store.destroy(&session_id);
```
Call `destroy_cookie` to also clear the browser cookie (see below).
### Purging expired sessions
Sessions are not removed automatically when they expire — they are simply invisible to `load`. To reclaim memory, call `purge_expired()` periodically:
```rust
store.purge_expired(); // removes all sessions whose TTL has elapsed
```
Use the [Scheduler](/features/scheduler/) to run this automatically:
```rust
use std::time::Duration;
use rust_web_server::scheduler::Scheduler;
let store_clone = store.clone();
Scheduler::new()
.every(Duration::from_secs(3600), move || store_clone.purge_expired())
.start();
```
## Cookie helpers
Three free functions bridge the session store and the HTTP cookie layer.
### `session_id_from_request`
Extracts the session ID from a named cookie in a request's `Cookie` header.
```rust
use rust_web_server::session;
let sid: Option = session::session_id_from_request(&request, "sid");
```
Returns `None` if the `Cookie` header is absent or the named cookie is missing.
### `session_cookie`
Builds a `Set-Cookie` header value with `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Max-Age`.
```rust
let cookie_value = session::session_cookie(&session.id, "sid", 3600);
response.headers.push(Header::new("Set-Cookie", cookie_value));
```
### `destroy_cookie`
Clears the browser cookie by setting `Max-Age=0`.
```rust
let cookie_value = session::destroy_cookie("sid");
response.headers.push(Header::new("Set-Cookie", cookie_value));
```
## Complete login/profile example
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::session::{self, SessionStore};
use rust_web_server::header::Header;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
struct State { sessions: SessionStore }
let app = App::with_state(State { sessions: SessionStore::new(3600) })
.post("/login", |req, _params, _conn, state| {
// Verify credentials here ...
let mut sess = state.sessions.create();
sess.set("user_id", "42");
state.sessions.save(&sess);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.headers.push(Header::new("Set-Cookie", session::session_cookie(&sess.id, "sid", 3600)));
r
})
.get("/profile", |req, _params, _conn, state| {
let mut r = Response::new();
let sid = match session::session_id_from_request(&req, "sid") {
Some(id) => id,
None => {
r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
return r;
}
};
let sess = match state.sessions.load(&sid) {
Some(s) => s,
None => {
r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
return r;
}
};
let _user_id = sess.get("user_id").unwrap_or("guest");
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
})
.get("/logout", |req, _params, _conn, state| {
let mut r = Response::new();
if let Some(sid) = session::session_id_from_request(&req, "sid") {
state.sessions.destroy(&sid);
}
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.headers.push(Header::new("Set-Cookie", session::destroy_cookie("sid")));
r
});
```
## Store size
```rust
let count = store.len(); // total entries, including expired-but-not-purged
let empty = store.is_empty(); // true when no entries exist
```
## DbSessionStore (database-backed)
`DbSessionStore` stores sessions in a `rws_sessions` table managed by the [model layer](/database/overview/). Sessions survive process restarts and are visible across multiple instances that share the same database.
### Setup
Requires the `model-sqlite`, `model-postgres`, or `model-mysql` feature. The table is created automatically on the first `DbSessionStore::new` call.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["model-sqlite"] }
```
### Basic usage
```rust
use rust_web_server::model::DbPool;
use rust_web_server::session::DbSessionStore;
// Use a file or network database for real persistence;
// :memory: is shown here for illustration.
let pool = DbPool::memory().await.unwrap();
let store = DbSessionStore::new(pool, 3600).await.unwrap();
// All methods are async and return Result<_, DbError>
let mut sess = store.create().await.unwrap();
sess.set("user_id", "42");
store.save(&sess).await.unwrap();
let loaded = store.load(&sess.id).await.unwrap().unwrap();
assert_eq!(Some("42"), loaded.get("user_id"));
store.destroy(&sess.id).await.unwrap();
```
### Purging expired sessions
Unlike `SessionStore`, expired rows remain in the database until explicitly deleted:
```rust
store.purge_expired().await.unwrap(); // DELETE WHERE expires_at <= now
```
Schedule this with [Scheduler](/features/scheduler/):
```rust
use std::time::Duration;
use rust_web_server::scheduler::Scheduler;
let store_clone = store.clone();
let rt = tokio::runtime::Handle::current();
Scheduler::new()
.every(Duration::from_secs(3600), move || {
let store = store_clone.clone();
rt.spawn(async move { let _ = store.purge_expired().await; });
})
.start();
```
### Schema
```sql
CREATE TABLE IF NOT EXISTS rws_sessions (
id TEXT PRIMARY KEY,
data TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL
);
```
Session data is serialized as a URL-encoded string (`key1=val1&key2=val2`).
## RedisSessionStore
`RedisSessionStore` stores sessions in Redis using a hand-rolled RESP v2 client — no external crate required. Sessions are keyed as `rws:sess:{id}` and given a Redis TTL via `SET … EX`, so they expire automatically without any `purge_expired` sweep.
### Basic usage
```rust
use rust_web_server::session::RedisSessionStore;
// Connect to Redis at host:port; pass Some("password") for AUTH
let store = RedisSessionStore::new("127.0.0.1:6379", None, 3600);
let mut sess = store.create().unwrap();
sess.set("role", "admin");
store.save(&sess).unwrap(); // SET rws:sess:{id} "role=admin" EX 3600
let loaded = store.load(&sess.id).unwrap().unwrap();
assert_eq!(Some("admin"), loaded.get("role"));
store.destroy(&sess.id).unwrap(); // DEL rws:sess:{id}
```
### Environment-variable configuration
```rust
use rust_web_server::session::RedisSessionStore;
// Reads RWS_REDIS_HOST (default 127.0.0.1), RWS_REDIS_PORT (default 6379),
// RWS_REDIS_PASSWORD (optional), RWS_REDIS_TTL_SECS (default 3600)
let store = RedisSessionStore::from_env();
```
### Connection behaviour
The store maintains one persistent TCP connection per `RedisSessionStore` instance. All clones share the same connection via `Arc`. The connection is established lazily on the first command and reconnects automatically if the socket is dropped.
:::caution[Thread safety]
The underlying TCP connection is protected by a `Mutex`. For high-concurrency workloads consider creating a pool of `RedisSessionStore` instances, each holding its own connection.
:::
---
# Email / SMTP
<https://rws8.tech/features/email/>
`rust-web-server` ships a hand-rolled SMTP client in `src/mailer/mod.rs` — no third-party mail library required. Enable the `mailer` feature, set `RWS_SMTP_*` environment variables, and call `Mailer::send()` from any handler.
STARTTLS and SMTPS (TLS) additionally require the `http-client` or `http2` feature at compile time. Plain SMTP on port 25 works with only `mailer`.
```toml
# Cargo.toml
[dependencies]
# STARTTLS or SMTPS (most SMTP providers)
rust-web-server = { version = "18", features = ["mailer", "http-client"] }
# Plain SMTP / local relay only
rust-web-server = { version = "18", features = ["mailer"] }
```
## Quick start
```rust,no_run
use rust_web_server::mailer::{Email, Mailer};
let mailer = Mailer::from_env().expect("SMTP not configured");
let email = Email::builder()
.to("user@example.com")
.subject("Welcome!")
.text("Thanks for signing up.")
.build()
.unwrap();
mailer.send(&email).expect("send failed");
```
## Configuration
`Mailer::from_env()` reads the following environment variables:
| Variable | Default | Required | Description |
|---|---|---|---|
| `RWS_SMTP_HOST` | — | **Yes** | SMTP server hostname |
| `RWS_SMTP_PORT` | `587` | No | Port (25 = relay, 587 = STARTTLS, 465 = SMTPS) |
| `RWS_SMTP_USER` | — | No | SMTP username; omit to skip AUTH |
| `RWS_SMTP_PASSWORD` | — | No | SMTP password |
| `RWS_SMTP_FROM` | — | **Yes** | Envelope and `From:` address |
| `RWS_SMTP_TLS` | `starttls` | No | `starttls`, `smtps`, or `none` |
| `RWS_SMTP_TIMEOUT_MS` | `10000` | No | Connect / read / write timeout in milliseconds |
### Provider examples
**Gmail (STARTTLS, port 587):**
```bash
RWS_SMTP_HOST=smtp.gmail.com
RWS_SMTP_PORT=587
RWS_SMTP_TLS=starttls
RWS_SMTP_USER=you@gmail.com
RWS_SMTP_PASSWORD=app-password # create an App Password in Google Account settings
RWS_SMTP_FROM=you@gmail.com
```
**SendGrid (STARTTLS, port 587):**
```bash
RWS_SMTP_HOST=smtp.sendgrid.net
RWS_SMTP_PORT=587
RWS_SMTP_TLS=starttls
RWS_SMTP_USER=apikey
RWS_SMTP_PASSWORD=SG.xxxx
RWS_SMTP_FROM=noreply@yourdomain.com
```
**Amazon SES (STARTTLS, port 587):**
```bash
RWS_SMTP_HOST=email-smtp.us-east-1.amazonaws.com
RWS_SMTP_PORT=587
RWS_SMTP_TLS=starttls
RWS_SMTP_USER=AKIAIOSFODNN7EXAMPLE
RWS_SMTP_PASSWORD=RWS_SMTP_FROM=noreply@yourdomain.com
```
**Local relay (no TLS, port 25):**
```bash
RWS_SMTP_HOST=127.0.0.1
RWS_SMTP_PORT=25
RWS_SMTP_TLS=none
RWS_SMTP_FROM=app@internal
```
## Direct construction
Bypass env vars and build a `Mailer` directly:
```rust,no_run
use rust_web_server::mailer::{Mailer, SmtpTls};
let mailer = Mailer {
host: "smtp.gmail.com".into(),
port: 587,
user: Some("you@gmail.com".into()),
password: Some("app-password".into()),
from: "you@gmail.com".into(),
tls: SmtpTls::Starttls,
timeout_ms: 15_000,
};
```
## Building emails
`Email::builder()` returns an `EmailBuilder` with a fluent API:
```rust,no_run
use rust_web_server::mailer::Email;
let email = Email::builder()
.to("user@example.com") // one or more To: recipients
.to("other@example.com")
.cc("manager@example.com") // optional Cc:
.bcc("archive@example.com") // envelope only; not in headers
.reply_to("support@example.com") // optional Reply-To:
.subject("Your order shipped")
.text("Your package is on the way.") // plain-text body
.html("<p>Your package is <b>on the way</b>.</p>") // HTML body
.build()
.unwrap();
```
`build()` validates:
- At least one `to` address
- Non-empty `subject`
- At least one of `text` or `html`
When both `text` and `html` are provided, the message is sent as `multipart/alternative` so email clients can display whichever format they support.
## Sharing the mailer in handlers
For production use, put the mailer in your application state so every handler can access it:
```rust,no_run
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::mailer::{Email, Mailer};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::range::Range;
use rust_web_server::mime_type::MimeType;
struct State {
mailer: Arc,
}
let state = State {
mailer: Arc::new(Mailer::from_env().unwrap()),
};
let app = App::with_state(state)
.post("/register", |req, _params, _conn, state| {
// … validate the request body …
let email = Email::builder()
.to("new_user@example.com")
.subject("Confirm your email")
.text("Please confirm: https://example.com/confirm?token=xxxx")
.html("<p>Click <a href=\"https://example.com/confirm?token=xxxx\">here</a> to confirm.</p>")
.build()
.unwrap();
// In production, send in a background task (see scheduler docs)
if let Err(e) = state.mailer.send(&email) {
eprintln!("email send error: {e}");
}
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(
b"registered".to_vec(), MimeType::TEXT_PLAIN.to_string(),
)];
r
});
```
## Use cases
| Use case | Setup |
|---|---|
| Password reset | Generate a CSPRNG token with `crypto::generate_token(32)`, store it in the DB, email the link |
| Email verification | Same pattern — send a link containing the token; verify on callback |
| Transactional notifications | Order confirmation, invoice, shipping notification |
| Alerts | Background scheduler job sends alert emails on threshold events |
## TLS modes
| Mode | Port | When to use |
|---|---|---|
| `SmtpTls::Starttls` (default) | 587 | Supported by virtually all providers; upgrades from plain to TLS |
| `SmtpTls::Smtps` | 465 | Implicit TLS from the first byte; required by some providers |
| `SmtpTls::None` | 25 | Local relay (`localhost`), internal networks, or providers that handle TLS at the network edge |
:::note[TLS requirements]
STARTTLS and SMTPS require `rustls`, which is included when you compile with `http-client` or `http2` features. If you compile with only `mailer` (no TLS features), `send()` returns `Err(MailerError::Smtp(...))` when `SmtpTls::Starttls` or `SmtpTls::Smtps` is configured.
:::
## Error handling
`Mailer::send()` returns `Result<(), MailerError>`:
```rust,no_run
use rust_web_server::mailer::{Mailer, MailerError};
match mailer.send(&email) {
Ok(()) => println!("sent"),
Err(MailerError::MissingConfig(var)) => eprintln!("missing env var: {var}"),
Err(MailerError::Io(e)) => eprintln!("network error: {e}"),
Err(MailerError::Smtp(msg)) => eprintln!("SMTP rejected: {msg}"),
Err(MailerError::Build(msg)) => eprintln!("bad email: {msg}"),
}
```
## Implementation notes
- **No third-party SMTP library.** The client hand-rolls RFC 5321 SMTP: TCP connect → EHLO → optional STARTTLS or SMTPS → optional AUTH PLAIN → MAIL FROM → RCPT TO (one per recipient) → DATA → message → QUIT.
- **RFC 5322 message builder.** `build_message()` assembles headers (`From`, `To`, `Cc`, `Subject`, `Reply-To`, `MIME-Version`, `Content-Type`) and the body. When both text and HTML are provided, a `multipart/alternative` structure is generated.
- **SMTP dot-stuffing.** Lines in the message body that begin with `.` are prefixed with an extra `.` (RFC 5321 §4.5.2) so the DATA terminator (`\r\n.\r\n`) is not triggered prematurely.
- **AUTH PLAIN.** Credentials are encoded as base64(`\0username\0password`) in a single AUTH PLAIN command using the built-in `crate::core::base64::Base64::encode`.
- **Sync only.** `Mailer::send()` blocks the calling thread. In async handlers (`AsyncAppWithState`), wrap the call in `tokio::task::spawn_blocking`.
---
# File / Object Storage
<https://rws8.tech/features/storage/>
`FormMultipartData::parse()` (see [Forms & File Uploads](/building-apps/forms-uploads/)) hands back raw bytes with no place to put them. The `storage` module gives handlers a single `Storage` trait so the same upload code works against local disk in development and an S3-compatible bucket in production.
```rust
pub trait Storage: Send + Sync {
fn put(&self, key: &str, data: &[u8], content_type: &str) -> Result;
fn get(&self, key: &str) -> Result, StorageError>;
fn delete(&self, key: &str) -> Result<(), StorageError>;
fn url(&self, key: &str) -> String;
}
```
`url()` performs no I/O and never fails — it just formats a string.
## Local disk (`storage-local` feature)
No new dependencies.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["storage-local"] }
```
```rust
use rust_web_server::storage::{LocalStorage, Storage};
let store = LocalStorage::new("/var/data/uploads");
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;
```
Keys are relative paths under the root directory; parent directories (e.g. `avatars/`) are created automatically. A key containing a `..` segment is rejected — the same defense used by the config-driven proxy's static-file action.
Content type is **not** persisted — plain files on disk have no metadata slot for it.
### Serving uploaded files back over HTTP
`LocalStorage::url()` returns the object's filesystem path by default, which isn't directly useful to a browser. If the storage root is also served as a static directory, call `.with_base_url()` so `url()` returns an HTTP path instead:
```rust
let store = LocalStorage::new("/var/data/uploads").with_base_url("/uploads");
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
assert_eq!("/uploads/avatars/42.png", store.url(&key));
```
Pair this with a [`Router`](/building-apps/routing/) route (or the config-driven proxy's `type = "static"` action — see [Config-Driven Proxy](/proxy/config-driven/)) that serves `/var/data/uploads` at the `/uploads` prefix.
## S3-compatible object storage (`storage-s3` feature)
Works with AWS S3, Cloudflare R2, MinIO, or any other S3-compatible provider. Signs every request with AWS Signature Version 4 using the existing outbound HTTP client (`hmac` + `sha2`) — **no AWS SDK dependency**.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["storage-s3"] }
```
```rust,no_run
use rust_web_server::storage::{S3Storage, Storage};
let store = S3Storage::from_env()?;
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;
let public_url = store.url(&key);
# Ok::<(), rust_web_server::storage::StorageError>(())
```
### Configuration
`S3Storage::from_env()` reads:
| Variable | Default |
|---|---|
| `RWS_S3_BUCKET` | **(required)** |
| `RWS_S3_REGION` | `us-east-1` |
| `RWS_S3_ACCESS_KEY` | optional — falls back to workload identity (below) when unset |
| `RWS_S3_SECRET_KEY` | optional — falls back to workload identity (below) when unset |
| `RWS_S3_ENDPOINT` | `https://s3.{region}.amazonaws.com` |
Point `RWS_S3_ENDPOINT` at a custom host to use a non-AWS provider:
```bash
# Cloudflare R2
RWS_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# MinIO (local development)
RWS_S3_ENDPOINT=http://localhost:9000
```
Or construct `S3Config` directly instead of reading the environment:
```rust
use rust_web_server::storage::{S3Config, S3Storage};
let store = S3Storage::new(S3Config {
bucket: "my-bucket".to_string(),
region: "us-east-1".to_string(),
access_key: "...".to_string(),
secret_key: "...".to_string(),
endpoint: "https://s3.us-east-1.amazonaws.com".to_string(),
});
```
### Workload identity (no static keys)
When `RWS_S3_ACCESS_KEY`/`RWS_S3_SECRET_KEY` are unset, `S3Storage` auto-detects short-lived credentials from the environment instead — no AWS SDK, no static keys to rotate or leak. Detection follows the same precedence AWS's own SDKs use:
| Source | Triggered by | Notes |
|---|---|---|
| EKS IRSA | `AWS_ROLE_ARN` + `AWS_WEB_IDENTITY_TOKEN_FILE` | Injected automatically by the EKS pod-identity webhook. Calls STS `AssumeRoleWithWebIdentity`. |
| ECS task role | `AWS_CONTAINER_CREDENTIALS_FULL_URI` or `_RELATIVE_URI` | Injected automatically by the ECS agent. |
| EC2 IMDSv2 | — (last resort) | Each request uses a short timeout so a non-EC2 host (local dev, CI, GCP, Azure) fails fast instead of hanging. |
Set `RWS_S3_CREDENTIAL_SOURCE=static|irsa|ecs|imds` to force a specific source and skip detection entirely — useful to guarantee a non-cloud host never even probes the EC2 metadata endpoint.
Credentials are cached in memory and refreshed automatically shortly before they expire — no per-request network overhead once a request has been signed at least once.
```bash
# On EKS/ECS/EC2, just omit the static keys — no code change needed:
RWS_S3_BUCKET=my-bucket
RWS_S3_REGION=us-east-1
```
### Addressing style
`S3Storage` always uses **path-style** addressing — `{endpoint}/{bucket}/{key}` — rather than virtual-hosted-style (`{bucket}.{host}/{key}`). Path-style works against every S3-compatible provider, including custom endpoints (R2, MinIO) where a wildcard DNS entry for virtual-hosted-style isn't set up.
:::note[Signing scope]
Every request signs `host`, `x-amz-content-sha256`, and `x-amz-date` — plus `x-amz-security-token` when using temporary/workload-identity credentials. This covers standard single-object `PUT`/`GET`/`DELETE` — there's no support for presigned URLs, multipart (chunked) uploads, or query-string signing.
:::
## Azure Blob Storage (`storage-azure` feature)
Signs every request with the Shared Key HMAC-SHA256 scheme using the existing outbound HTTP client (`hmac` + `sha2`) — **no Azure SDK dependency**.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["storage-azure"] }
```
```rust,no_run
use rust_web_server::storage::{AzureBlobStorage, Storage};
let store = AzureBlobStorage::from_env()?;
let key = store.put("avatars/42.png", &file_bytes, "image/png")?;
let bytes = store.get(&key)?;
store.delete(&key)?;
let public_url = store.url(&key);
# Ok::<(), rust_web_server::storage::StorageError>(())
```
### Configuration
`AzureBlobStorage::from_env()` reads:
| Variable | Default |
|---|---|
| `RWS_AZURE_ACCOUNT` | **(required)** |
| `RWS_AZURE_CONTAINER` | **(required)** |
| `RWS_AZURE_ACCOUNT_KEY` | optional — falls back to Managed Identity (below) when unset |
| `RWS_AZURE_ENDPOINT` | `https://{account}.blob.core.windows.net` |
Point `RWS_AZURE_ENDPOINT` at a custom host to use the Azurite local emulator or a private endpoint:
```bash
RWS_AZURE_ENDPOINT=http://127.0.0.1:10000/devstoreaccount1
```
Or construct `AzureBlobConfig` directly instead of reading the environment:
```rust
use rust_web_server::storage::{AzureBlobConfig, AzureBlobStorage};
let store = AzureBlobStorage::new(AzureBlobConfig {
account: "myaccount".to_string(),
container: "my-container".to_string(),
account_key: "...".to_string(),
endpoint: "https://myaccount.blob.core.windows.net".to_string(),
});
```
### Workload identity (no static keys)
When `RWS_AZURE_ACCOUNT_KEY` is unset, `AzureBlobStorage` auto-detects a Managed Identity OAuth token instead — no Azure SDK, no static account key to rotate or leak:
| Source | Triggered by | Notes |
|---|---|---|
| App Service / Container Apps | `IDENTITY_ENDPOINT` + `IDENTITY_HEADER` | Injected automatically by the platform. |
| VM / AKS IMDS | — (last resort) | Each request uses a short timeout so a non-Azure host (local dev, CI, AWS, GCP) fails fast instead of hanging. |
Set `RWS_AZURE_CREDENTIAL_SOURCE=key|managed-identity` to force a specific source and skip detection entirely. Tokens are cached in memory and refreshed automatically shortly before they expire.
```bash
# On AKS, a VM, App Service, or Container Apps, just omit the account key —
# no code change needed:
RWS_AZURE_ACCOUNT=myaccount
RWS_AZURE_CONTAINER=my-container
```
:::note[Signing scope]
Shared Key requests sign every `x-ms-*` header actually sent — `x-ms-date`, `x-ms-version`, and `x-ms-blob-type` on `PUT` — plus the standard `Content-Type`/`Content-Length` slots. Managed Identity requests skip signing entirely and use `Authorization: Bearer {token}` instead. This covers standard single-blob `PUT`/`GET`/`DELETE` — there's no support for SAS tokens, blob listing, or multipart (block list) uploads.
:::
## Large objects: `put_stream`
`put_stream(key, reader, content_type)` stores whatever a `std::io::Read`
yields, without holding it all in memory — pair it with a
[streamed upload](/building-apps/forms-uploads/#streaming-large-uploads),
whose `&request.body` is a reader (since 18.0.0):
```rust
use rust_web_server::storage::{S3Storage, Storage};
let store = S3Storage::from_env()?;
// In a route registered with .stream_body("PUT", "/upload/:name"):
store.put_stream("uploads/video.mp4", &mut &request.body, "video/mp4")?;
```
- `S3Storage` uses an S3 multipart upload: 8 MiB parts (`S3_PART_SIZE`)
read and sent one at a time, so memory stays at one part; objects up to
~78 GiB (S3's 10 000-part limit). Anything that fits in one part is a
plain `PUT`. A failure aborts the upload, so no orphaned parts are left
behind. Tested end to end with a 2 GiB HTTP upload against an
S3-compatible server.
- `LocalStorage` writes to a temporary file beside the target as it reads
and renames it into place, so a failed upload never replaces the old file.
- `AzureBlobStorage` and custom backends use the default: read everything,
then `put`.
## Writing handler code against `Storage`
Depend on the trait, not a concrete type, so the same function works with any backend:
```rust
use rust_web_server::storage::{Storage, StorageError};
fn save_avatar(store: &dyn Storage, user_id: u64, bytes: &[u8]) -> Result {
let key = format!("avatars/{user_id}.png");
store.put(&key, bytes, "image/png")
}
```
Wire `store` in via [dependency injection](/building-apps/dependency-injection/) or [app state](/building-apps/state/) so handlers don't construct a new `LocalStorage`/`S3Storage`/`AzureBlobStorage` per request.
---
# Response Caching
<https://rws8.tech/features/caching/>
`CacheLayer` is a middleware that short-circuits the inner application for cacheable `GET` responses, serving them directly from an in-memory store until their TTL elapses. It lives in `src/cache/mod.rs` and implements the standard `Middleware` trait, so it composes with every application variant.
## Basic setup
```rust
use rust_web_server::app::App;
use rust_web_server::cache::CacheLayer;
use rust_web_server::core::New;
let app = App::new()
.wrap(CacheLayer::memory(1000).ttl(60));
```
This caches up to 1 000 GET responses for 60 seconds each.
## Constructor and builder methods
### `CacheLayer::memory(capacity)`
Creates a new in-memory cache bounded to `capacity` entries. The default TTL is **60 seconds**.
```rust
let layer = CacheLayer::memory(500);
```
### `.ttl(secs)`
Overrides the time-to-live for all cached entries.
```rust
let layer = CacheLayer::memory(500).ttl(300); // 5 minutes
```
### `.vary_by_header(name)`
Includes a request header in the cache key so that different values of that header produce separate cache entries. The name match is case-insensitive. Chain multiple calls to vary by more than one header.
```rust
let layer = CacheLayer::memory(500)
.vary_by_header("Accept-Language")
.vary_by_header("Accept");
```
Without `.vary_by_header`, all requests to the same URI share a single cache entry regardless of any request headers.
## What is cached
| Condition | Cached? |
|-----------|---------|
| Method is GET | Yes (if other conditions pass) |
| Method is POST, PUT, DELETE, etc. | No — bypasses cache entirely |
| Response status 2xx (200, 201, 203, 204, 206, …) | Yes |
| Response status 3xx, 4xx, 5xx | No |
| Response `Cache-Control: no-store` | No |
| Response `Cache-Control: private` | No |
| Response `Cache-Control: no-cache` (static files by default) | No — it may only be reused after revalidation, which this cache doesn't do |
| Response `Vary` names a request header that isn't in the key | Not stored — unless it changed nothing: `Origin` without `Access-Control-Allow-Origin`, or `Accept-Encoding` with an unencoded body. `Vary: *` is never stored |
| Request with `Authorization`, `Cookie` or `Range` | No — bypasses cache entirely (the response may be per user or partial; since 17.113.1) |
| Response with `Set-Cookie` | No (since 17.113.1) |
| Streamed response (`stream_file` / `stream_pipe`) | No — the body isn't held in memory (since 17.113.1) |
| Request `Cache-Control: no-cache` | Bypass hit, call handler, **store** fresh response |
## Cache key
The cache key is the request URI combined with the values of every header named in `.vary_by_header()`. Two requests to the same path but with different `Accept-Language` values produce separate entries when `vary_by_header("Accept-Language")` is configured.
## `Age` header
On cache hits, the response receives an `Age` header indicating how many seconds have elapsed since the entry was inserted:
```
Age: 42
```
If the response already carries an `Age` header, its value is replaced.
## Eviction
When the store is at capacity and a new entry must be inserted, the **oldest** entry (by insertion time) is evicted. Expired entries are purged before every insert, so a full store of expired entries makes room before falling back to oldest-first eviction.
## Cache statistics
`CacheLayer` is cheaply `Clone`-able — all clones share the same backing store. Keep a handle to query statistics or invalidate the cache while the other clone is used as middleware:
```rust
let cache = CacheLayer::memory(1000).ttl(60);
let cache_handle = cache.clone(); // shared reference
let app = App::new().wrap(cache);
// Later, from a handler or admin endpoint:
let hits = cache_handle.hits(); // u64
let misses = cache_handle.misses(); // u64
let size = cache_handle.size(); // current entry count
```
### `cache.clear()`
Evicts all entries immediately:
```rust
cache_handle.clear();
```
## Full example
```rust
use rust_web_server::app::App;
use rust_web_server::cache::CacheLayer;
use rust_web_server::core::New;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
let cache = CacheLayer::memory(200)
.ttl(120)
.vary_by_header("Accept-Language");
let cache_handle = cache.clone();
let app = App::with_state(cache_handle)
.get("/api/products", |_req, _params, _conn, cache| {
// This response will be cached for 120 s per Accept-Language value.
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
})
.get("/admin/cache/stats", |_req, _params, _conn, cache| {
let body = format!(
"hits={} misses={} size={}",
cache.hits(), cache.misses(), cache.size()
);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
})
.wrap(cache.clone()); // middleware layer applied outermost
```
:::note[Middleware ordering]
`.wrap()` layers are applied in push order — the first `.wrap()` call is the outermost layer. Place `CacheLayer` last (innermost wrap position) if you want rate limiting or auth to run before the cache is consulted.
:::
---
# Metrics
<https://rws8.tech/features/metrics/>
`rust-web-server` exposes two tiers of Prometheus-compatible metrics. Server-wide counters are always active at the server-core level with no configuration. Per-route counters and latency histograms are opt-in via `MetricsLayer` middleware. All metrics are served at `GET /metrics` in the standard Prometheus text format.
## Server-wide counters
These atomics are updated by `Server::process()` and the connection dispatch loop automatically. No middleware registration is required.
| Symbol | Type | Description |
|--------|------|-------------|
| `REQUESTS_TOTAL` | counter | Total HTTP requests handled across all connections and protocols |
| `ERRORS_TOTAL` | counter | Requests where `app.execute()` returned `Err` |
| `ACTIVE_CONNECTIONS` | gauge | Number of currently open TCP/QUIC connections |
| `SERVER_READY` | `AtomicBool` | `true` after server setup; `false` during shutdown; drives `/readyz` |
### Helper functions
```rust
use rust_web_server::metrics;
metrics::record_request(); // increments REQUESTS_TOTAL
metrics::record_error(); // increments ERRORS_TOTAL
metrics::connection_open(); // increments ACTIVE_CONNECTIONS
metrics::connection_close(); // decrements ACTIVE_CONNECTIONS
```
These functions are called by the server core, but you can also call them from custom instrumentation code.
## `GET /metrics` endpoint
The built-in endpoint returns all metrics in Prometheus text format. No setup is needed — it is registered automatically by `App::new()`.
```
# HELP rws_requests_total Total HTTP requests handled
# TYPE rws_requests_total counter
rws_requests_total 12483
# HELP rws_errors_total HTTP requests that returned an application error
# TYPE rws_errors_total counter
rws_errors_total 3
# HELP rws_active_connections Currently open connections
# TYPE rws_active_connections gauge
rws_active_connections 7
```
When `MetricsLayer` is active, per-route metrics are appended to the same response body.
## Per-route metrics with `MetricsLayer`
Wrap your application once at startup to enable per-route request counts and latency histograms:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::metrics::MetricsLayer;
let app = App::new().wrap(MetricsLayer);
```
`MetricsLayer` records two metric families per `(method, path)` pair after each request completes.
### `rws_route_requests_total`
A counter labelled by method, path, and HTTP status code.
```
# HELP rws_route_requests_total Total requests handled per route
# TYPE rws_route_requests_total counter
rws_route_requests_total{method="GET",path="/api/users",status="200"} 9401
rws_route_requests_total{method="GET",path="/api/users",status="404"} 12
rws_route_requests_total{method="POST",path="/api/users",status="201"} 530
```
Query strings are stripped from the path label automatically (`/users?page=2` → `/users`).
### `rws_route_duration_seconds`
A cumulative histogram labelled by method and path, with 11 predefined buckets matching the default Prometheus/Go client boundaries:
`0.005`, `0.01`, `0.025`, `0.05`, `0.1`, `0.25`, `0.5`, `1.0`, `2.5`, `5.0`, `10.0` (seconds)
```
# HELP rws_route_duration_seconds Request duration in seconds per route
# TYPE rws_route_duration_seconds histogram
rws_route_duration_seconds_bucket{method="GET",path="/api/users",le="0.005"} 8200
rws_route_duration_seconds_bucket{method="GET",path="/api/users",le="0.01"} 9100
...
rws_route_duration_seconds_bucket{method="GET",path="/api/users",le="+Inf"} 9413
rws_route_duration_seconds_sum{method="GET",path="/api/users"} 47.231000000
rws_route_duration_seconds_count{method="GET",path="/api/users"} 9413
```
### Behaviour notes
- A handler that returns `Err` is recorded with status `500`.
- The per-route section is absent from `/metrics` until at least one route has been observed.
- `metrics::record_route(method, path, status, elapsed_secs)` records a request directly, for custom instrumentation without `MetricsLayer`:
```rust
use rust_web_server::metrics;
metrics::record_route("GET", "/jobs/run", 200, 0.042);
```
## Circuit breaker state
`rws_circuit_breaker_state{backend}` is included automatically — no `MetricsLayer` or other opt-in needed — for every backend known to [`circuit_breaker::global()`](/proxy/circuit-breaker/):
```
# HELP rws_circuit_breaker_state Circuit breaker state per backend (0=closed, 1=half_open, 2=open)
# TYPE rws_circuit_breaker_state gauge
rws_circuit_breaker_state{backend="api-1:8080"} 0
rws_circuit_breaker_state{backend="api-2:8080"} 2
```
The gauge value is `0` (Closed/healthy), `1` (HalfOpen/probing), or `2` (Open/unhealthy). Wiring `ReverseProxy::with_circuit_breaker(Arc::new(circuit_breaker::global()))` gets you this metric "for free" alongside the automatic proxy integration, since both read from the same breaker instance. `RedisCircuitBreaker` state doesn't appear here — the minimal hand-rolled RESP client has no `SCAN`/`KEYS` support to enumerate its keys.
## `SERVER_READY` and `/readyz`
The built-in `GET /readyz` controller returns `200 OK` when `SERVER_READY` is `true` and `503 Service Unavailable` when it is `false`. The flag is set to `true` after `Server::setup()` completes and back to `false` when a shutdown signal is received, so Kubernetes stops routing traffic before the pod exits.
```rust
use rust_web_server::metrics::SERVER_READY;
use std::sync::atomic::Ordering;
// Check readiness programmatically.
let ready = SERVER_READY.load(Ordering::Relaxed);
```
## Scraping with Prometheus
```yaml
# prometheus.yml
scrape_configs:
- job_name: rws
static_configs:
- targets: ["localhost:7878"]
metrics_path: /metrics
```
---
# Distributed Tracing
<https://rws8.tech/features/tracing/>
`rust-web-server` includes an OpenTelemetry-compatible distributed tracing implementation in `src/otel/mod.rs`. `OtelLayer` middleware creates an HTTP server root span for every request, reads and propagates W3C `traceparent` headers, and exports spans to stdout or an OTLP-compatible collector (Jaeger, Grafana Tempo, OpenTelemetry Collector). Application code can nest child spans under that root (or under each other) with `otel::span`/`otel::client_span` — see [Child spans](#child-spans) below.
## Setup
Call `otel::setup()` once at startup before the server starts accepting requests. Calling it more than once is a no-op — the first call wins.
### Development: print spans to stdout
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::otel::{OtelLayer, TracingConfig, ExporterConfig};
rust_web_server::otel::setup(TracingConfig {
service_name: "my-service".to_string(),
service_version: env!("CARGO_PKG_VERSION").to_string(),
exporter: ExporterConfig::Stdout,
sample_rate: 1.0,
batch_size: 128,
});
let app = App::new().wrap(OtelLayer);
```
Each completed span is printed as a single JSON line to stdout, suitable for piping into `jq` or a log aggregator.
### Production: OTLP HTTP export
```rust
use rust_web_server::otel::{ExporterConfig, TracingConfig};
rust_web_server::otel::setup(TracingConfig {
service_name: "my-service".to_string(),
service_version: "1.0.0".to_string(),
exporter: ExporterConfig::Otlp {
endpoint: "http://jaeger:4318".to_string(),
},
sample_rate: 0.1, // sample 10 % of requests
batch_size: 512,
});
```
The OTLP exporter sends spans to `POST {endpoint}/v1/traces` using JSON encoding. It is compatible with Jaeger >= 1.35, Grafana Tempo, and the OpenTelemetry Collector. The default OTLP HTTP port is `4318`.
### Environment variable setup
`setup_from_env()` reads standard OpenTelemetry environment variables. Use this when the exporter endpoint is injected by the platform (Kubernetes, Docker Compose, etc.):
```bash
export OTEL_SERVICE_NAME=my-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
export OTEL_TRACES_SAMPLER_ARG=0.1 # 0.0–1.0, default 1.0
```
```rust
rust_web_server::otel::setup_from_env();
let app = App::new().wrap(OtelLayer);
```
When `OTEL_EXPORTER_OTLP_ENDPOINT` is absent or empty, the exporter falls back to stdout.
## `TracingConfig` fields
| Field | Type | Description |
|-------|------|-------------|
| `service_name` | `String` | `service.name` resource attribute |
| `service_version` | `String` | `service.version` resource attribute |
| `exporter` | `ExporterConfig` | `Stdout`, `Otlp { endpoint }`, or `Discard` |
| `sample_rate` | `f64` | Fraction of requests to trace (`0.0`–`1.0`) |
| `batch_size` | `usize` | Spans accumulated before a flush to the exporter |
## W3C `traceparent` propagation
`OtelLayer` reads the `traceparent` header from each incoming request using the W3C Trace Context format (`00-{trace-id}-{parent-id}-{flags}`):
- If present, the existing trace ID is continued and the incoming span ID becomes the parent.
- If absent, a new trace ID is generated.
The active span context is stored in a **thread-local stack** for the duration of the request — not just a single slot — so nested [child spans](#child-spans) work correctly: starting a child span pushes it, and it's popped when the child ends, restoring its parent as "current."
## `otel::current_traceparent()`
Retrieve the `traceparent` value for the *innermost* span currently active on the calling thread — the request's root span, or whichever child span is currently open, whichever is deeper. Use this to propagate trace context into outbound HTTP requests made inside a handler:
```rust
use rust_web_server::otel;
use rust_web_server::http_client::Client;
// Inside a handler, while OtelLayer is active:
let mut builder = Client::new().get("http://other-service/api/data");
if let Some(tp) = otel::current_traceparent() {
builder = builder.header("traceparent", &tp);
}
let response = builder.send()?;
```
Returns `None` when no span is active on the current thread.
## Span attributes
Each span records standard HTTP semantic attributes:
| Attribute | Example |
|-----------|---------|
| `http.method` | `"GET"` |
| `http.target` | `"/api/users?page=2"` |
| `http.status_code` | `200` |
| span `name` | `"GET /api/users"` (query stripped) |
Spans with a 5xx status code receive `status.code = 2` (Error). All others receive `0` (Unset).
## Child spans
`OtelLayer` only creates one span per request — the root. To see *where* time was spent inside that request (a slow DB query vs. a slow upstream call vs. everything else), start a **child span** with `otel::span` or `otel::client_span` wherever that work happens:
```rust
use rust_web_server::otel;
fn get_user(id: u64) -> Result {
let span = otel::span("db.query"); // SpanKind::Internal
span.set_attribute("db.statement", "SELECT * FROM users WHERE id = ?");
span.set_attribute("db.user_id", id as i64);
let result = run_query(id);
if result.is_err() {
span.record_error("query failed");
}
result
// `span` is recorded here, when it drops (or call `span.end()` explicitly
// for the exact same effect, sooner).
}
```
A span created while another is active becomes its **child**: it shares the enclosing span's `trace_id`, and its `parent_span_id` is the enclosing span's `span_id` — this is what makes a proper trace waterfall show up in Jaeger/Tempo instead of a flat list of unrelated spans. Spans can nest arbitrarily deep. A child also **inherits its parent's sampling decision** — if the root wasn't sampled, none of its children are either, so traces are never partially recorded.
| Function | `SpanKind` | Use for |
|---|---|---|
| `otel::span(name)` | `Internal` | Work with no remote counterpart — a DB query, a cache lookup, business logic |
| `otel::client_span(name)` | `Client` | An outbound call to another service — pair with `otel::current_traceparent()` to propagate context into it |
| `Span::new(name, kind)` | any `SpanKind` | Full control, if neither helper fits |
`Span` mutation methods (all take `&self` — no `mut` binding needed):
| Method | Effect |
|---|---|
| `span.set_attribute(key, value)` | Attach a `String`/`i64`/`f64`/`bool` attribute (anything implementing `Into`) |
| `span.set_error()` | Mark the span `status.code = 2` (Error) |
| `span.record_error(message)` | `set_error()` plus an `error.message` attribute |
| `span.trace_id()` / `.span_id()` / `.parent_span_id()` | Read the span's own IDs |
| `span.end()` | End the span now — identical to letting it drop at the end of scope |
A `Span` must be dropped on the thread that created it (it's not `Send`) — moving one across threads and dropping it elsewhere would corrupt that other thread's span stack.
## Shutdown
Flush all buffered spans before the process exits:
```rust
rust_web_server::otel::shutdown();
```
Calling `shutdown()` flushes the internal batch and invokes the exporter's own shutdown hook. Without this call, the last batch of spans may not be delivered.
## Testing
Use `ExporterConfig::Discard` in tests to silence output without changing application code paths:
```rust
rust_web_server::otel::setup(TracingConfig {
exporter: ExporterConfig::Discard,
..TracingConfig::default()
});
```
`flush()` is also available for non-destructive flushing in test assertions.
To assert on the actual spans a test produced, use `setup_with_exporter` with `CapturingExporter` instead of `setup`/`ExporterConfig` — it accepts any `Box<dyn Exporter>` directly:
```rust,no_run
use rust_web_server::otel::{self, CapturingExporter, TracingConfig, ExporterConfig};
use std::sync::Arc;
let captured = Arc::new(CapturingExporter::new());
otel::setup_with_exporter(
TracingConfig { exporter: ExporterConfig::Discard, ..Default::default() },
Box::new(captured.clone()),
);
otel::span("db.query").end();
otel::flush();
assert_eq!(1, captured.take().len());
```
Note that `setup`/`setup_with_exporter` initialize a process-wide singleton (first call wins) — in a test binary where other tests also call `setup`, only one of them actually takes effect for the whole process, so this pattern works best in an isolated test binary or when you control which `setup*` call runs first.
---
# Hot Config Reload
<https://rws8.tech/features/hot-reload/>
## Triggering a reload
Two mechanisms trigger a hot reload:
```bash
# Unix signal — sends SIGHUP to the running server
kill -HUP $(pgrep rws)
# or by PID
kill -HUP $(pidof rws)
```
```bash
# HTTP endpoint — no request body required
curl -X POST http://localhost:8080/admin/config/reload
```
Both re-parse `rws.config.toml`, update process environment variables, apply the new rate-limit thresholds to the live `RateLimiter`, and publish a new `ConfigSnapshot` atomically. On `http2`/`http3` builds, `SIGHUP` also rebuilds the `TlsAcceptor` with fresh certificates for every virtual host. If the `tera` feature is enabled and `template::init()` was called, both also re-glob Tera templates from disk — see [Templates: hot reload](/building-apps/templates/#hot-reload).
Request handling reads settings from a cached snapshot (`ServerConfig::current()`), not from the environment on every request; a reload rebuilds it, so changes apply from the next request. A library user who changes an `RWS_CONFIG_*` variable with `std::env::set_var` calls `rust_web_server::server_config::invalidate()` afterwards for the same effect.
Each reload prints a confirmation line:
```text
Config reloaded — cors_allow_all=false rate_limit=1000/60 log_format=json
```
### Reloading when the file changes
With `watch = true` (`RWS_CONFIG_WATCH`, since 18.1.0) rws reloads by itself
when `rws.config.toml` (or `RWS_CONFIG_FILE`) or any configured TLS
certificate or key changes — as Caddy's `--watch` does:
```toml
watch = true
```
A background thread checks the files' size and modification time once a
second; a change that is the same on two checks in a row (an editor or
`certbot` finished writing) triggers the same reload as `SIGHUP`, so a
broken edit is rejected and the running configuration keeps serving. The
files are looked up again on every check, so a reload that points at new
certificates starts watching those. Off by default; the `watch` setting
itself is read at startup.
## What reloads without restart
| Setting | Env var |
|---|---|
| CORS — all fields | `RWS_CONFIG_CORS_*` |
| Rate-limit max requests | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` |
| Rate-limit window (seconds) | `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
| Log format (`json` / `combined` / `off`) | `RWS_CONFIG_LOG_FORMAT` |
| Request allocation size | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
| Max body size (applies to new connections) | `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` |
| Client hints, static `Cache-Control` | `RWS_CONFIG_CLIENT_HINTS`, `RWS_CONFIG_STATIC_CACHE_CONTROL` |
| SPA fallback | `RWS_CONFIG_SPA_FALLBACK`, `RWS_CONFIG_SPA_FALLBACK_EXCLUDE_PREFIXES` |
| TLS certificates (http2+ builds) | cert/key files re-read from `RWS_CONFIG_TLS_CERT_FILE`, `RWS_CONFIG_TLS_KEY_FILE` and every virtual host; the paths themselves are fixed at startup |
| Tera templates (`tera` feature) | re-read from the directory passed to `template::init()` — see [Templates: hot reload](/building-apps/templates/#hot-reload) |
| Proxy routes, upstreams, health checks, `[middleware]` | `[[route]]`, `[[upstream]]`, `[middleware]` in `rws.config.toml` — see below |
## Reloading proxy routes
In proxy mode (a config file with `[[route]]` or `[[upstream]]` sections), a
reload also rebuilds the routes. Edit the file, check it, then signal:
```bash
rws --check && kill -HUP $(pgrep rws)
```
The new file is validated first — the same checks as
[`rws --check`](/configuration/config-file/#check-the-file-before-you-deploy).
If it has errors, they are logged and **the running routes keep serving**; a
typo never takes the proxy down:
```text
[proxy_config] reload ERROR: route 'api': unknown action type 'proxxy' (expected proxy, grpc, static, redirect or respond)
[proxy_config] rws.config.toml has errors — keeping the running routes
```
Otherwise the new routes take effect for the next request, requests already
in progress finish on the old ones, health checkers for the old upstreams stop
and new ones start. A reload of an unchanged file does nothing. To reload from
your own code, see
[Reloading without a restart](/proxy/config-driven/#reloading-without-a-restart).
## What requires a restart
| Setting | Why |
|---|---|
| IP address | Bound socket cannot be moved |
| Port | Bound socket cannot be moved |
| Thread count | Thread pool is fixed at startup |
| `[[tcp_proxy]]`, `[[udp_proxy]]`, `[[ws_proxy]]` | Their listener sockets are bound at startup; a reload that changes them logs a warning |
## `ConfigSnapshot`
Call `config_reload::current()` anywhere in the handler stack to get a typed snapshot of all hot-reloadable values at that instant. The call takes a brief read lock and clones a handful of strings — safe to call on every request.
```rust
use rust_web_server::config_reload;
fn my_handler(req: &Request, conn: &ConnectionInfo) -> Response {
let cfg = config_reload::current();
if cfg.cors_allow_all {
// serve with open CORS
}
println!(
"rate limit: {}/{} log: {}",
cfg.rate_limit_max_requests,
cfg.rate_limit_window_secs,
cfg.log_format,
);
Response::new()
}
```
### `ConfigSnapshot` fields
| Field | Type | Source env var |
|---|---|---|
| `cors_allow_all` | `bool` | `RWS_CONFIG_CORS_ALLOW_ALL` |
| `cors_allow_origins` | `String` | `RWS_CONFIG_CORS_ALLOW_ORIGINS` |
| `cors_allow_methods` | `String` | `RWS_CONFIG_CORS_ALLOW_METHODS` |
| `cors_allow_headers` | `String` | `RWS_CONFIG_CORS_ALLOW_HEADERS` |
| `cors_allow_credentials` | `String` | `RWS_CONFIG_CORS_ALLOW_CREDENTIALS` |
| `cors_expose_headers` | `String` | `RWS_CONFIG_CORS_EXPOSE_HEADERS` |
| `cors_max_age` | `String` | `RWS_CONFIG_CORS_MAX_AGE` |
| `rate_limit_max_requests` | `u32` | `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` |
| `rate_limit_window_secs` | `u64` | `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` |
| `log_format` | `String` | `RWS_CONFIG_LOG_FORMAT` |
| `request_allocation_size` | `i64` | `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` |
| `max_body_size` | `u64` | `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` |
## Triggering reload from code
`RELOAD_REQUESTED` is an `AtomicBool` in the `config_reload` module. Set it to `true` to trigger a reload on the next connection cycle, or call `config_reload::reload()` directly from any thread:
```rust
use rust_web_server::config_reload;
// Option A: set the flag (picked up between connections)
config_reload::RELOAD_REQUESTED.store(true, std::sync::atomic::Ordering::SeqCst);
// Option B: call reload() immediately from a handler
fn reload_handler(_req: &Request, _conn: &ConnectionInfo) -> Response {
config_reload::reload();
Response::new() // 200 OK
}
```
## Pattern: dynamic behaviour from live config
```rust
fn rate_limit_info(_req: &Request, _conn: &ConnectionInfo) -> Response {
let cfg = config_reload::current();
let body = format!(
r#"{{"max_requests":{},"window_secs":{}}}"#,
cfg.rate_limit_max_requests, cfg.rate_limit_window_secs
);
Response::json(STATUS_CODE_REASON_PHRASE.n200_ok, body.into_bytes())
}
```
:::note[Signal safety]
The `SIGHUP` handler does only one thing: it stores `true` into the `AtomicBool`. The actual `reload()` call happens on the main thread between connection accepts — no allocation, no I/O, no locks inside the signal handler.
:::
---
# Request/Response Rewriting
<https://rws8.tech/features/rewrite/>
## Quick start
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::rewrite::RewriteLayer;
let app = App::new()
.wrap(
RewriteLayer::new()
.request_header_set("X-Env", "production")
.request_uri_strip_prefix("/api/v1")
.response_header_set("Cache-Control", "no-store")
.response_body_replace("http://staging.internal", "https://example.com"),
);
```
## How it works
`RewriteLayer` clones the incoming `Request`, applies request rules in registration order, calls the next handler, then applies response rules in registration order on the returned `Response`.
Rules are applied sequentially. If you register `.request_header_set("X-Foo", "a")` and then `.request_header_set("X-Foo", "b")`, the handler receives `X-Foo: b`.
## Request rewriting
Applied before the request reaches any handler.
### `.request_header_set(name, value)`
Add or replace a request header. Case-insensitive name match — existing headers with the same name are removed first.
```rust
.request_header_set("X-Forwarded-Proto", "https")
.request_header_set("Authorization", "Bearer internal-token")
```
### `.request_header_remove(name)`
Remove a request header (case-insensitive). No-op if the header is absent.
```rust
.request_header_remove("X-Debug-Token")
```
### `.request_uri_set(uri)`
Replace the entire request URI.
```rust
.request_uri_set("/v2/canonical-path")
```
### `.request_uri_strip_prefix(prefix)`
Remove a path prefix from the URI. No-op if the prefix is not present. Normalizes to `"/"` if stripping leaves an empty path.
```rust
// /api/v1/users → /users
.request_uri_strip_prefix("/api/v1")
```
### `.request_uri_add_prefix(prefix)`
Prepend a prefix to the request URI.
```rust
// /users → /internal/users
.request_uri_add_prefix("/internal")
```
### `.request_uri_regex_rewrite(pattern, replacement)` (requires `rewrite-regex` feature)
The operations above cover fixed strings. When the rewrite depends on part of the incoming path — API versioning, locale prefixes, extracting an ID — match the URI against a regex and rewrite it using the match's capture groups. Semantics are the same as nginx's `rewrite` directive: if `pattern` matches anywhere in the URI, the **entire** URI is replaced by `replacement` with capture groups expanded (`$1`, `$2`, ... for numbered groups, `${name}` for named groups); if it doesn't match, the URI is left unchanged.
```toml
# Cargo.toml
rust-web-server = { version = "18", features = ["rewrite-regex"] }
```
```rust
use rust_web_server::rewrite::RewriteLayer;
// /api/v1/users/42 → /users/42
let layer = RewriteLayer::new()
.request_uri_regex_rewrite(r"^/api/v\d+/(.*)$", "/$1")?;
// Named captures: /fr/products → /products?locale=fr
let layer = RewriteLayer::new()
.request_uri_regex_rewrite(r"^/(?P<locale>[a-z]{2})/(?P<rest>.*)$", "/$rest?locale=$locale")?;
```
Unlike the other `.request_*`/`.response_*` builders, this one returns `Result` — an invalid pattern is a compile-time-unchecked but very real failure mode, so chain it with `?` rather than treating it as infallible.
## Response rewriting
Applied after the handler returns, before the response is sent to the client.
### `.response_header_set(name, value)`
Add or replace a response header. Case-insensitive name match.
```rust
.response_header_set("X-Powered-By", "rws")
.response_header_set("Strict-Transport-Security", "max-age=31536000")
```
### `.response_header_remove(name)`
Remove a response header (case-insensitive). No-op if absent.
```rust
.response_header_remove("Server")
.response_header_remove("X-Powered-By")
```
### `.response_status(code, reason)`
Override the response status code and reason phrase.
```rust
// Treat all upstream 404s as 200 for a SPA fallback
.response_status(200, "OK")
```
### `.response_body_replace(from, to)`
Byte-level find-and-replace across all response body content ranges. Uses a linear non-overlapping scan.
```rust
// Rewrite staging URLs in proxied HTML responses
.response_body_replace("http://staging.internal:3000", "https://api.example.com")
```
## Composing with other middleware
`RewriteLayer` implements `Middleware` and stacks with all other middleware layers. Layers are applied in push order (first `.wrap()` call is outermost):
```rust
use rust_web_server::middleware::RateLimitLayer;
let app = App::new()
.wrap(RateLimitLayer) // outermost — checked first
.wrap(
RewriteLayer::new()
.request_uri_strip_prefix("/api")
.response_header_set("Cache-Control", "no-store"),
);
```
## Full example: API gateway strip + label
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::rewrite::RewriteLayer;
use rust_web_server::proxy::ReverseProxy;
let rewrite = RewriteLayer::new()
// Strip the public prefix before forwarding
.request_uri_strip_prefix("/api/v2")
// Tag requests for the upstream service
.request_header_set("X-Gateway", "rws")
// Hide internal server identity from clients
.response_header_remove("Server")
// Add security header
.response_header_set("X-Content-Type-Options", "nosniff");
let app = App::new()
.wrap(rewrite)
.wrap(ReverseProxy::new(["http://127.0.0.1:9000"]));
```
:::note[Rule ordering]
Request rules and response rules are each applied in the order they were registered. Chain multiple `.request_*` and `.response_*` calls freely — they compose left-to-right.
:::
---
# IP Filtering
<https://rws8.tech/features/ip-filter/>
## Quick start
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::ip_filter::IpFilter;
// Allowlist — only internal networks pass
let app = App::new()
.wrap(IpFilter::allow(["10.0.0.0/8", "192.168.0.0/16", "127.0.0.1"]));
// Denylist — block a known-bad range
let app = App::new()
.wrap(IpFilter::deny(["1.2.3.4", "5.6.7.0/24"]));
```
## Modes
### `IpFilter::allow(entries)`
**Allowlist mode.** Only requests from IPs that match one of the entries pass through. All others receive `403 Forbidden`.
```rust
IpFilter::allow([
"127.0.0.1", // exact loopback
"10.0.0.0/8", // entire 10.x.x.x range
"192.168.1.0/24", // specific subnet
])
```
### `IpFilter::deny(entries)`
**Denylist mode.** Requests from IPs that match one of the entries receive `403 Forbidden`. All others pass through.
```rust
IpFilter::deny([
"1.2.3.4", // single bad actor
"192.0.2.0/24", // TEST-NET-1 documentation range
])
```
### Combining both
Stack two filters to allow a network but carve out one address inside it — a request must pass every layer:
```rust
let app = App::new()
.wrap(IpFilter::allow(["10.0.0.0/8"]))
.wrap(IpFilter::deny(["10.0.0.99"]));
```
## Entry formats
Each entry in the list is an exact IPv4 or IPv6 address, or a CIDR range:
| Format | Example | Matches |
|---|---|---|
| Exact address | `"10.0.0.1"` | Only `10.0.0.1` |
| CIDR /8 | `"10.0.0.0/8"` | `10.0.0.0` – `10.255.255.255` |
| CIDR /16 | `"192.168.0.0/16"` | `192.168.0.0` – `192.168.255.255` |
| CIDR /24 | `"192.168.1.0/24"` | `192.168.1.0` – `192.168.1.255` |
| CIDR /32 | `"10.0.0.1/32"` | Only `10.0.0.1` |
| IPv6 address | `"2001:db8::1"` | Only `2001:db8::1` |
| IPv6 CIDR | `"2001:db8::/32"` | `2001:db8::` – `2001:db8:ffff:…` |
Malformed entries (invalid syntax, a prefix longer than 32 for IPv4 or 128
for IPv6) are skipped at construction time. `ip_filter::is_valid_entry(entry)`
tells you whether an entry parses, and `rws --check` reports invalid entries in
`rws.config.toml`.
## IPv4 and IPv6 clients
`rws` listens on IPv4 and IPv6. An IPv4 client reaching a dual-stack socket is
reported as an IPv4-mapped address (`::ffff:10.1.2.3`); IPv4 rules match it
exactly as they match `10.1.2.3`. IPv4 rules never match native IPv6 clients
and vice versa, so a deny list meant for everyone needs both families — for
example `["0.0.0.0/0", "::/0"]`.
## Use case: restrict admin routes to internal network
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::ip_filter::IpFilter;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use std::sync::Arc;
struct State;
let admin_only = IpFilter::allow(["10.0.0.0/8", "127.0.0.1"]);
let app = App::with_state(Arc::new(State))
.get("/admin/metrics", |_req, _params, _conn, _state| {
// only reachable from internal IPs
Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, "metrics data")
})
.wrap(admin_only);
```
For more granular control — applying the filter only to `/admin/*` routes rather than the whole app — wrap a separate app that holds only those routes instead of the top-level app:
```rust
use rust_web_server::app::App;
use rust_web_server::ip_filter::IpFilter;
// An app with only the admin routes, wrapped with IP filtering
let protected = App::with_state(())
.get("/admin/config", admin_config_handler)
.get("/admin/metrics", admin_metrics_handler)
.wrap(IpFilter::allow(["10.0.0.0/8", "127.0.0.1"]));
```
## Composing with other middleware
`IpFilter` implements `Middleware` and stacks cleanly with rate limiting, authentication, and rewrite layers:
```rust
use rust_web_server::middleware::RateLimitLayer;
use rust_web_server::auth::BasicAuthLayer;
let app = App::new()
.wrap(IpFilter::allow(["10.0.0.0/8"])) // checked first
.wrap(BasicAuthLayer::new(|u, p| u == "user" && p == "pass"))
.wrap(RateLimitLayer);
```
:::note[Client IP source]
The client IP is read from `ConnectionInfo::client.ip`, which is the remote address of the TCP connection. If the server sits behind a load balancer or reverse proxy, configure the proxy to forward the real client IP and adjust your handler logic accordingly — `IpFilter` does not inspect `X-Forwarded-For` headers.
:::
---
# Request ID / Correlation ID
<https://rws8.tech/features/request-id/>
Distributed tracing ([`OtelLayer`](/features/tracing/)) creates a span per request, but doesn't give handlers a simple, stable identifier to put in their own log lines — and doesn't propagate one across service boundaries unless the caller already sends a W3C `traceparent`. `RequestIdLayer` fills that gap with a plain string ID, present on both the request (so your handler can read and log it) and the response (so the caller can log the same value).
No new Cargo feature or dependency — always available.
## Quick start
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::request_id::RequestIdLayer;
let app = App::new().wrap(RequestIdLayer::new());
```
- If the incoming request already has an `X-Request-Id` header — set by an upstream gateway, load balancer, or calling service — that exact value is kept and echoed back unchanged. One ID follows the request across every hop instead of getting a new one at each service.
- Otherwise, a fresh ID is generated with `generate_request_id()` and injected into the request **before** your handler runs, so handlers can read it like any other header.
- Either way, the same value is always set on the response.
## Reading it in a handler
Use the `RequestId` extractor:
```rust
use rust_web_server::extract::{FromRequest, RequestId};
use rust_web_server::request::Request;
fn handler(request: &Request) {
let id = RequestId::from_request(request).unwrap();
println!("[{}] handling request", id.as_str());
}
```
`RequestId::from_request` never fails — if `RequestIdLayer` isn't wrapping the app (or somehow the header is missing), `id.as_str()` is just `""`.
Or read the header directly, like any other:
```rust
use rust_web_server::request::Request;
use rust_web_server::request_id::DEFAULT_HEADER;
fn handler(request: &Request) {
let id = request.get_header(DEFAULT_HEADER.to_string()).map(|h| h.value.as_ref()).unwrap_or("");
}
```
## Custom header name
```rust
use rust_web_server::request_id::RequestIdLayer;
let layer = RequestIdLayer::new().header("X-Correlation-Id");
```
## Ordering with other middleware
`RequestIdLayer` composes like any other layer — `.wrap()` calls apply in push order, first-pushed is outermost. Register it **first** if you want every other middleware in the stack (including `OtelLayer` or your own access-logging middleware) to see the same ID:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::request_id::RequestIdLayer;
use rust_web_server::otel::OtelLayer;
let app = App::new()
.wrap(RequestIdLayer::new()) // outermost — runs first, sees the response last
.wrap(OtelLayer);
```
## The ID itself
`generate_request_id()` produces a UUID-v4-*shaped* string (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) — familiar in logs, but **not** a spec-compliant UUID (version/variant bits aren't forced) and **not** cryptographically random. It's built from a monotonic counter mixed with the current timestamp via a splitmix64 finalizer, the same non-crypto technique this crate already uses for other unique-but-not-secret IDs (e.g. session IDs).
:::caution[Not a security token]
Don't use `generate_request_id()` (or the ID `RequestIdLayer` generates) as a session ID, CSRF token, password reset token, or anywhere uniqueness must be adversarially guaranteed. For that, use the `crypto` feature's `generate_token()`, which is backed by a CSPRNG.
:::
## Relationship to `OtelLayer`
`X-Request-Id` and `OtelLayer`'s W3C `traceparent` solve related but different problems: `X-Request-Id` is a simple, human-readable, application-level correlation string meant for grepping log files; `traceparent` carries the OpenTelemetry trace/span context used by distributed tracing backends (Jaeger, Tempo, etc.). Use both together — they don't conflict, and `RequestIdLayer` doesn't touch OTEL's own headers.
---
# Background Scheduler
<https://rws8.tech/features/scheduler/>
## Quick start
```rust
use std::time::Duration;
use rust_web_server::scheduler::Scheduler;
Scheduler::new()
.every(Duration::from_secs(60), || purge_expired_sessions())
.after(Duration::from_secs(30), || emit_heartbeat())
.cron("0 * * * * *", || rotate_logs()).unwrap()
.initial_delay(Duration::from_secs(10))
.start();
```
`.start()` spawns one OS thread per registered task and returns immediately. Threads run for the lifetime of the process.
## Task types
### `.every(interval, task)` — fixed rate
The interval is measured from the **start** of the previous run. If the task takes longer than the interval, the next run starts immediately (no backlog accumulates).
```rust
// Purge sessions every 60 seconds, measured from task start
.every(Duration::from_secs(60), || {
let removed = session_store.purge_expired();
println!("purged {} sessions", removed);
})
```
### `.after(delay, task)` — fixed delay
The delay is measured from the **end** of the previous run. Use this when you want a guaranteed pause between runs regardless of how long the task takes.
```rust
// Wait 30 seconds after each heartbeat before sending the next
.after(Duration::from_secs(30), || {
health_check::emit_heartbeat();
})
```
### `.cron(expr, task)` — cron schedule
Fires whenever the wall-clock time (UTC) matches the 6-field cron expression. Returns `Result` — use `.unwrap()` or propagate the error at startup.
```rust
// Every day at midnight UTC
.cron("0 0 0 * * *", || daily_report::generate()).unwrap()
// Every hour on the hour
.cron("0 0 * * * *", || cache::warm()).unwrap()
// Every 15 seconds
.cron("*/15 * * * * *", || metrics::flush()).unwrap()
```
## `.initial_delay(duration)`
Adds a one-time startup delay before the **most recently registered task** runs for the first time. Chain it immediately after the task it applies to.
```rust
Scheduler::new()
.every(Duration::from_secs(60), || purge_sessions())
.initial_delay(Duration::from_secs(10)) // wait 10 s before first purge
.after(Duration::from_secs(30), || heartbeat())
// no initial_delay here — heartbeat starts immediately
.start();
```
## 6-field cron syntax
Format: `"second minute hour day-of-month month day-of-week"` (UTC)
| Field | Range | Examples |
|---|---|---|
| second | 0–59 | `0`, `*/15`, `0,30` |
| minute | 0–59 | `*`, `0`, `5-10` |
| hour | 0–23 | `*`, `0`, `9-17` |
| day-of-month | 1–31 | `*`, `1`, `15` |
| month | 1–12 | `*`, `1`, `6-8` |
| day-of-week | 0–6 (0=Sun) | `*`, `1-5`, `0,6` |
### Supported field syntax
| Syntax | Meaning |
|---|---|
| `*` | Every value |
| `N` | Exact value `N` |
| `*/step` | Every `step`-th value starting from minimum |
| `N-M` | Range from `N` to `M` inclusive |
| `a,b,c` | Comma-separated list of any of the above |
### Examples
```
0 0 * * * * every minute at second 0
0 */5 * * * * every 5 minutes
0 0 2 * * * daily at 02:00 UTC
0 0 9-17 * * * top of every hour, 09:00–17:00 UTC
0 0 0 1 * * first day of every month at midnight
0 0 0 * * 1 every Monday at midnight (1 = Monday)
0,30 * * * * * every 30 seconds
```
## Complete example: session cleanup + cache warm
```rust
use std::time::Duration;
use rust_web_server::scheduler::Scheduler;
fn start_background_tasks(db: Arc, cache: Arc) {
let db1 = db.clone();
let cache1 = cache.clone();
Scheduler::new()
// Purge expired sessions every hour
.cron("0 0 * * * *", move || {
db1.execute("DELETE FROM sessions WHERE expires_at < NOW()").ok();
})
.unwrap()
// Warm the cache 5 seconds after startup, then every 10 minutes
.every(Duration::from_secs(600), move || {
cache1.warm_from_db(&db);
})
.initial_delay(Duration::from_secs(5))
.start();
}
```
:::note[Thread per task]
Each registered task gets its own dedicated OS thread. The thread runs for the lifetime of the process. Use `Arc` to share state across tasks; do not capture `&mut` references.
:::
:::note[Cron resolution]
Cron tasks poll at 200 ms resolution. A task is guaranteed to fire at most once per second — if the task finishes within the same second it started, the next poll skips that second to prevent double-firing.
:::
---
# Background Job Queue
<https://rws8.tech/features/jobs/>
Requires the `jobs` feature:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["jobs"] }
```
There are two queue types:
- **`JobQueue`** — in-memory worker pool. Simple, no extra dependencies. Queued and retrying jobs are lost if the process crashes or restarts.
- **`PersistentJobQueue`** — backed by the model layer (`model-sqlite` / `model-postgres` / `model-mysql`). Jobs are written to a `rws_jobs` table before being acknowledged, so pending and retrying jobs survive a restart.
## `JobQueue` quick start
```rust
use rust_web_server::jobs::JobQueue;
let queue = JobQueue::new(4); // 4 worker threads
// In a request handler, after committing the signup:
let to = "new-user@example.com".to_string();
queue.submit(move || {
// send_welcome_email(&to) — return Err(msg) to trigger a retry
Ok(())
});
```
`submit` returns immediately; the job runs on one of the queue's worker threads.
### Retry with backoff
A job that returns `Err` is retried on the same worker thread with exponential
backoff. Configure the policy with `.max_retries()` / `.backoff()` (defaults:
3 retries, 500 ms initial backoff, 2x multiplier):
```rust
use rust_web_server::jobs::JobQueue;
use std::time::Duration;
let queue = JobQueue::new(4)
.max_retries(5)
.backoff(Duration::from_millis(200), 2);
```
`max_retries` counts retries **after** the first attempt — `max_retries(2)`
allows 3 total attempts (1 initial + 2 retries) before giving up. A job that
exhausts its retries is logged and dropped.
:::note[Backoff blocks the worker]
Retries sleep on the same worker thread that picked up the job — a job that
retries repeatedly occupies one worker for the duration of its backoff. Size
the pool with that in mind, or keep retry counts low for jobs expected to fail
often.
:::
### Named jobs
Implement the `Job` trait directly for jobs that need state or a stable name
in log lines, instead of a closure:
```rust
use rust_web_server::jobs::Job;
struct SendWelcomeEmail {
to: String,
}
impl Job for SendWelcomeEmail {
fn run(&self) -> Result<(), String> {
// send_welcome_email(&self.to)
Ok(())
}
fn name(&self) -> &str {
"send_welcome_email" // used in retry/failure log lines; defaults to "job"
}
}
queue.submit(SendWelcomeEmail { to: "new-user@example.com".to_string() });
```
### Shutdown
```rust
queue.join(); // stop accepting new jobs; wait for in-flight jobs (and their retries) to finish
```
`join()` is mainly useful in tests and at process shutdown — most long-running
servers simply let the queue live for the process lifetime.
## `PersistentJobQueue`
Additionally requires a `model-*` feature:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["jobs", "model-sqlite"] }
```
Because a closure can't be serialized to disk, persisted jobs are `(job_type,
payload)` string pairs, dispatched at execution time to a handler registered
by name. Register every `job_type` your process enqueues **before** starting
workers — this includes the process that comes back up after a crash, since
recovery works by replaying rows from the `rws_jobs` table, not by replaying
in-memory closures.
```rust,no_run
# async fn example() -> Result<(), rust_web_server::model::DbError> {
use rust_web_server::jobs::PersistentJobQueue;
use rust_web_server::model::DbPool;
use std::sync::Arc;
let pool = DbPool::from_env().await?;
let queue = Arc::new(PersistentJobQueue::new(pool).await?);
queue.register("send_welcome_email", |payload| {
// send_welcome_email(payload) — payload is whatever string you enqueued
Ok(())
});
let _worker_handles = Arc::clone(&queue).start(4); // 4 polling worker threads
// From a request handler:
queue.enqueue("send_welcome_email", "new-user@example.com").await?;
# Ok(())
# }
```
`queue.stop()` tells the workers started by `start()` to exit after their
current poll; join the returned handles to wait for them.
### Crash safety
`PersistentJobQueue::new()` creates the `rws_jobs` table if it doesn't exist,
then resets any row left `status = 'running'` back to `pending` — those rows
were being processed when the previous instance crashed or was killed, so they
get picked up again on the next poll.
Completed jobs are deleted from the table. A job that exhausts its retries is
left with `status = 'failed'` and its last error in `last_error`, for
inspection, instead of being deleted.
### Configuration
```rust,no_run
# async fn example() -> Result<(), rust_web_server::model::DbError> {
use rust_web_server::jobs::PersistentJobQueue;
use rust_web_server::model::DbPool;
use std::time::Duration;
let pool = DbPool::from_env().await?;
let queue = PersistentJobQueue::new(pool).await?
.max_retries(5) // default: 3 (retries after the first attempt)
.backoff(Duration::from_millis(500), 2) // default: 500ms initial, 2x multiplier
.poll_interval(Duration::from_millis(250)); // how often idle workers check for due jobs (default: 500ms)
# Ok(())
# }
```
Use `enqueue_with_retries(job_type, payload, max_retries)` to override the
retry count for a single job instead of the queue-wide default.
### Testing without a background poll loop
`tick()` runs a single poll-claim-execute cycle and returns whether a job was
claimed — useful in tests, or for embedding the queue in a caller-owned loop
instead of `start()`'s dedicated worker threads:
```rust,no_run
# async fn example() -> Result<(), rust_web_server::model::DbError> {
use rust_web_server::jobs::PersistentJobQueue;
use rust_web_server::model::DbPool;
let pool = DbPool::memory().await?;
let queue = PersistentJobQueue::new(pool).await?;
queue.register("noop", |_payload| Ok(()));
queue.enqueue("noop", "").await?;
assert_eq!(true, queue.tick().await?); // ran the job
assert_eq!(false, queue.tick().await?); // nothing left to do
# Ok(())
# }
```
:::note[Concurrency]
`claim_next` claims a row with `UPDATE rws_jobs SET status = 'running' WHERE id = ? AND status = 'pending'`
— if two workers (in the same process or different processes sharing the
same database) race to claim the same row, only one `UPDATE` succeeds, so the
other worker simply moves on. This is safe under SQLite, PostgreSQL, and MySQL.
:::
---
# OpenAPI / Swagger
<https://rws8.tech/features/openapi/>
Requires the `openapi` feature:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["openapi"] }
```
No API consumers reading source code or hand-maintained spec files that drift out of sync — the spec is built from the same route registrations your app already has.
## Quick start
```rust
use rust_web_server::app::App;
use rust_web_server::openapi::OpenApiConfig;
use rust_web_server::response::Response;
use rust_web_server::core::New;
struct Db;
let app = App::with_state(Db)
.get("/users", |_req, _params, _conn, _db| Response::new())
.get("/users/:id", |_req, _params, _conn, _db| Response::new())
.post("/users", |_req, _params, _conn, _db| Response::new())
// Call .openapi() last — it snapshots the routes registered so far.
.openapi(OpenApiConfig::new("My API", "1.0.0").description("Example API"));
```
This adds two routes on top of whatever you already registered:
- **`GET /openapi.json`** — the generated OpenAPI 3.0.3 document (`Content-Type: application/json`)
- **`GET /docs`** — Swagger UI, loaded from the `unpkg.com/swagger-ui-dist` CDN, pointed at `/openapi.json`
`AsyncAppWithState::openapi(config)` (requires `http2`) works identically for apps with `async fn` handlers.
:::caution[Call `.openapi()` last]
The spec is built once, at the point `.openapi()` is called, from whatever routes exist at that moment. Routes registered *after* `.openapi()` still work normally — they're just not in the generated spec.
:::
## What's in the generated spec
`OpenApiConfig::new(title, version)` sets `info.title` and `info.version`; `.description(...)` is optional. Path parameters are converted automatically:
| Router pattern | OpenAPI path | `parameters` entry |
|---|---|---|
| `/users` | `/users` | none |
| `/users/:id` | `/users/{id}` | `{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }` |
| `/files/*path` | `/files/{path}` | same shape, best-effort (OpenAPI has no native "rest of path" wildcard) |
Multiple HTTP methods on the same path are merged into a single `paths` entry, matching the OpenAPI structure — e.g. `GET /users` and `POST /users` both appear under `"paths": { "/users": { "get": {...}, "post": {...} } }`.
## Scope: paths and methods, not request/response bodies
Every generated operation has a generic `200 OK` response and no request or response body schema:
```json
{
"get": {
"summary": "GET /users/{id}",
"responses": { "200": { "description": "OK" } },
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }]
}
}
```
This is a deliberate scope boundary, not an oversight: Rust has no runtime type reflection, so producing a JSON Schema for a handler's request body type or a `#[derive(Validate)]` struct would require deriving schema metadata at the macro level — a substantially larger, separate feature. If you need full body schemas today:
- Post-process the generated JSON string to inject `requestBody`/response schemas for specific operations, or
- Build the spec yourself with [`build_spec`](#using-build_spec-directly) from a hand-written `Vec`, or
- Maintain a small hand-written OpenAPI fragment for the handful of routes where body shape matters most, and merge it with the generated output.
## Using `build_spec` directly
The lower-level `build_spec` function takes a route list and a config, and returns the spec as a `String` — useful for inspecting the output, writing it to a file at build time, or merging it with hand-written schema fragments:
```rust
use rust_web_server::openapi::{build_spec, OpenApiConfig};
use rust_web_server::router::RouteInfo;
let routes = vec![
RouteInfo { method: "GET".to_string(), pattern: "/users/:id".to_string() },
];
let spec_json = build_spec(&OpenApiConfig::new("My API", "1.0.0"), &routes);
```
`AppWithState::route_entries()` / `AsyncAppWithState::route_entries()` / `Router::route_entries()` all return the `Vec` that `.openapi()` uses internally, if you want the same route snapshot for something else.
---
# Per-Route Timeouts
<https://rws8.tech/features/timeouts/>
A single global read timeout (30s per connection; a request head must also arrive within 30s of its first byte, else `408`) applies to every route by default. A file-upload endpoint may legitimately need 120s while a health check should fail fast at 500ms. `rust_web_server::timeout` wraps individual handlers with their own budget — no new Cargo feature required, no new dependencies.
## `Router` / stateless handlers
```rust
use rust_web_server::router::Router;
use rust_web_server::timeout::with_timeout;
use rust_web_server::response::Response;
use rust_web_server::core::New;
use std::time::Duration;
let router = Router::new()
.get("/healthz", with_timeout(Duration::from_millis(500), |_req, _params, _conn| Response::new()))
.post("/upload", with_timeout(Duration::from_secs(120), |_req, _params, _conn| Response::new()));
```
If the wrapped handler doesn't return within `duration`, the caller gets `504 Gateway Timeout` immediately instead of waiting further.
## `AppWithState`
```rust
use rust_web_server::app::App;
use rust_web_server::timeout::with_timeout_state;
use rust_web_server::response::Response;
use rust_web_server::core::New;
use std::time::Duration;
#[derive(Clone)]
struct Db; // holds e.g. an Arc internally — cheap to clone
let app = App::with_state(Db)
.get("/healthz", with_timeout_state(Duration::from_millis(500), |_req, _params, _conn, _db| Response::new()))
.post("/upload", with_timeout_state(Duration::from_secs(120), |_req, _params, _conn, _db| Response::new()));
```
:::note[Why `S: Clone`?]
The handler signature only gives you `&S` (a borrow), but the timeout wrapper runs the call on a background thread, which needs its own owned copy. Most `AppWithState` state types already hold their real data behind `Arc` fields internally and are cheap to `#[derive(Clone)]`. If yours genuinely can't be `Clone`, wrap the whole app with [`TimeoutLayer`](#wrapping-a-whole-application) instead, or switch that route to `AsyncAppWithState` — see below.
:::
## `AsyncAppWithState` (requires `http2`)
```rust
use rust_web_server::app::App;
use rust_web_server::timeout::with_timeout_async;
use rust_web_server::response::Response;
use rust_web_server::core::New;
use std::time::Duration;
struct Db;
let app = App::with_async_state(Db)
.post("/upload", with_timeout_async(Duration::from_secs(120), |_req, _params, _conn, _db| async {
Response::new()
}));
```
No `Clone` bound needed — `AsyncAppWithState` already passes state as an owned `Arc`. This variant is also the only one with **genuine cancellation**: it's backed by `tokio::time::timeout`, and dropping a suspended `Future` actually stops its execution at the next `.await` point.
## Wrapping a whole `Application`
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::timeout::TimeoutLayer;
use std::time::Duration;
let app = TimeoutLayer::new(App::new(), Duration::from_secs(5));
```
Use `TimeoutLayer` when you want one blanket deadline around an entire app rather than different deadlines per route. `TimeoutLayer::from_arc(Arc<dyn Application + ...>, duration)` wraps an already-shared `Arc` — this is how the config-driven proxy applies a route's `timeout_ms` internally.
## Config-driven proxy
Add `timeout_ms` as a flat key under `[route.middleware]` — it bounds that route's *total* time, including its other middleware:
```toml
[[route]]
name = "slow-upload"
[route.match]
path = "/upload"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "backend"
[route.middleware]
timeout_ms = 120000
```
`0` or an absent `timeout_ms` means no timeout — the route behaves as it did before this feature existed.
## The honest limitation
Rust cannot forcibly stop a thread that's already running. `with_timeout`, `with_timeout_state`, and `TimeoutLayer` all run the wrapped work on a background thread and race it against `duration` using a channel:
- If the work finishes first, its result is returned normally.
- If the deadline passes first, the caller gets `504 Gateway Timeout` **immediately** — the calling thread does not wait for the slow work to finish.
- The slow work itself, however, is **not stopped**. It keeps running to completion on its background thread; its eventual result is simply discarded when it tries to send it back.
This means these timeouts bound the **client's wait time**, not the handler's actual CPU/memory/connection usage. For a slow database query or a hung TCP connection to a backend, the underlying resource is still consumed until that call naturally finishes (or its own lower-level timeout — e.g. a DB driver's own query timeout, or `DynamicProxy`'s `connect_timeout_ms`/`read_timeout_ms` — cuts it off first).
`with_timeout_async` is the one exception: because tokio futures are cooperatively scheduled, dropping a `Future` that's suspended at an `.await` point genuinely stops it from making further progress. If you need the underlying work to actually stop at the deadline — not just the client-facing response — use `AsyncAppWithState` and `with_timeout_async`.
---
# Typed Config Binding
<https://rws8.tech/features/config-binding/>
## Setup
Enable the `macros` feature in `Cargo.toml`:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["macros"] }
```
## Quick start
```rust
use rust_web_server::Config;
#[derive(rust_web_server::Config)]
#[config(prefix = "APP_")]
struct AppConfig {
#[config(env = "PORT", default = "8080")]
port: u16,
#[config(env = "DATABASE_URL")]
database_url: String,
#[config(env = "DEBUG")]
debug: Option<bool>,
}
fn main() {
let cfg = AppConfig::load().expect("invalid config");
println!("listening on port {}", cfg.port);
}
```
## `#[derive(Config)]`
The derive macro generates a single method on the struct:
```rust
impl AppConfig {
pub fn load() -> Result { ... }
}
```
`load()` reads every field from environment variables and returns `Err(String)` with a descriptive message on the first parse failure.
## Struct-level attribute
```rust
#[config(prefix = "APP_")]
```
When set, every field's environment variable key is `prefix + key`. With `prefix = "APP_"` and a field named `port`, the default env var is `APP_PORT`.
## Field-level attribute
```rust
#[config(env = "MY_PORT", default = "8080")]
```
| Option | Meaning |
|---|---|
| `env = "KEY"` | Explicit env var name. The struct prefix is still prepended. |
| `default = "v"` | Fallback string when the env var is absent. Parsed with the same `FromEnvStr` impl as a present value. |
If `env` is omitted the field name is uppercased (`pool_size` → `POOL_SIZE`), then the prefix is prepended.
## Field derivation rules
| Field declaration | Env var absent | Env var present |
|---|---|---|
| `field: T` with `default` | parse `default` value | parse env var |
| `field: T` without `default` | `Err` — required | parse env var |
| `field: Option` | `Ok(None)` (also when empty) | parse env var, wrap in `Some` |
## `FromEnvStr` trait
All primitive Rust scalar types implement `FromEnvStr` out of the box:
`String`, `bool`, `u8`, `u16`, `u32`, `u64`, `u128`, `usize`, `i8`, `i16`, `i32`, `i64`, `i128`, `isize`, `f32`, `f64`
`bool` accepts `"true"`, `"1"`, `"yes"` → `true`; `"false"`, `"0"`, `"no"` → `false`.
Implement `FromEnvStr` on your own type to use it as a config field:
```rust
use rust_web_server::config_binding::FromEnvStr;
#[derive(Debug)]
enum LogLevel { Error, Warn, Info, Debug }
impl FromEnvStr for LogLevel {
fn from_env_str(s: &str) -> Result {
match s.trim().to_ascii_lowercase().as_str() {
"error" => Ok(LogLevel::Error),
"warn" => Ok(LogLevel::Warn),
"info" => Ok(LogLevel::Info),
"debug" => Ok(LogLevel::Debug),
other => Err(format!("unknown log level: {other}")),
}
}
}
```
## Low-level helpers
The functions the derive expands to are public, for reading a single value without a struct (no `#[derive]` needed):
```rust
use rust_web_server::config_binding::{load_optional, load_required, load_with_default};
let port: u16 = load_with_default("APP_PORT", "8080")?;
let db: String = load_required("DATABASE_URL")?;
let debug: Option<bool> = load_optional("APP_DEBUG")?;
```
Each returns `Result<_, String>` with the same error messages as `load()`.
## Complete example
```rust
use rust_web_server::config_binding::FromEnvStr;
use rust_web_server::Config;
#[derive(Debug)]
enum LogLevel { Error, Warn, Info, Debug }
impl FromEnvStr for LogLevel {
fn from_env_str(s: &str) -> Result {
match s.trim().to_ascii_lowercase().as_str() {
"error" => Ok(LogLevel::Error),
"warn" => Ok(LogLevel::Warn),
"info" => Ok(LogLevel::Info),
"debug" => Ok(LogLevel::Debug),
other => Err(format!("unknown log level: {other}")),
}
}
}
#[derive(Debug, rust_web_server::Config)]
#[config(prefix = "APP_")]
struct AppConfig {
/// APP_HOST — defaults to "0.0.0.0"
#[config(env = "HOST", default = "0.0.0.0")]
host: String,
/// APP_PORT — defaults to 8080
#[config(env = "PORT", default = "8080")]
port: u16,
/// APP_DB_HOST — required, no default
#[config(env = "DB_HOST")]
db_host: String,
/// APP_DB_PORT — defaults to 5432
#[config(env = "DB_PORT", default = "5432")]
db_port: u16,
/// APP_DB_POOL_SIZE — defaults to 10
#[config(env = "DB_POOL_SIZE", default = "10")]
db_pool_size: u32,
/// APP_LOG_LEVEL — optional; None if not set
#[config(env = "LOG_LEVEL")]
log_level: Option,
}
fn main() {
let cfg = AppConfig::load().unwrap_or_else(|e| {
eprintln!("Config error: {e}");
std::process::exit(1);
});
println!("starting on {}:{}", cfg.host, cfg.port);
println!("db: {}:{} pool={}", cfg.db_host, cfg.db_port, cfg.db_pool_size);
if let Some(level) = cfg.log_level {
println!("log level: {:?}", level);
}
}
```
Set environment variables before running:
```bash
export APP_DB_HOST=postgres.internal
export APP_LOG_LEVEL=info
cargo run
```
:::note[Error messages]
`load()` returns `Err` on the first failure with a message like `` required env var `APP_DB_HOST` is not set `` or `` `APP_PORT`: invalid digit found in string ``. Check the message to identify which variable caused the problem.
:::
:::note[No config file]
`#[derive(Config)]` reads only from environment variables. To support `.env` files or `rws.config.toml`, set those variables in the environment before calling `load()`, or use a crate like `dotenvy` to load a `.env` file at the top of `main()`.
:::
---
# Webhook Signature Verification
<https://rws8.tech/features/webhooks/>
Requires the `webhook` feature:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["webhook"] }
```
Any handler that accepts webhooks from a third party needs to confirm the request actually came from that provider — otherwise anyone who finds (or guesses) the endpoint URL can send a forged payload. `hmac` and `sha2` are already dependencies elsewhere in `rws` (`auth`, `crypto`, `storage-s3`); this feature just adds the provider-specific conventions on top so you don't re-derive them from each provider's docs.
## Quick start
```rust
use rust_web_server::webhook::{verify_webhook_signature, WebhookProvider};
fn handle_github_webhook(body: &[u8], signature_header: &str, secret: &[u8]) -> bool {
verify_webhook_signature(WebhookProvider::GitHub, body, secret, signature_header)
}
```
:::caution[Use the raw request body]
`body` must be the exact bytes read off the wire. Re-serializing a parsed `Json` value before verifying will not reproduce the byte sequence the provider actually signed, and verification will always fail. Read it via the `Body` extractor or `request.body` directly.
:::
## Supported providers
| Provider | Header | Format |
|---|---|---|
| GitHub | `X-Hub-Signature-256` | `sha256=<hex-hmac-sha256-of-body>` |
| Shopify | `X-Shopify-Hmac-Sha256` | `<base64-hmac-sha256-of-body>` |
| Stripe | `Stripe-Signature` | `t=<unix_ts>,v1=<hex>[,v1=<hex>...]` |
Call the per-provider function directly when the provider is known at the call site — this is equivalent to `verify_webhook_signature` with the matching `WebhookProvider` variant:
```rust
use rust_web_server::webhook::{verify_github_signature, verify_shopify_signature, verify_stripe_signature};
verify_github_signature(body, secret, header_value);
verify_shopify_signature(body, secret, header_value);
verify_stripe_signature(body, secret, header_value);
```
:::note[GitHub's legacy `X-Hub-Signature` header]
GitHub also sends an older SHA-1-based `X-Hub-Signature` for backward compatibility. `verify_github_signature` only supports `X-Hub-Signature-256` — SHA-1 is cryptographically broken and this crate has no SHA-1 dependency. Always prefer the SHA-256 header; GitHub recommends it for all new integrations.
:::
## Stripe's timestamp tolerance
Stripe signs `"{timestamp}.{body}"`, not the body alone, and expects you to reject requests whose timestamp is too far from the current time — this stops a captured request from being replayed later. `verify_stripe_signature` checks against a default 300-second (5-minute) window, matching Stripe's own recommendation:
```rust
use rust_web_server::webhook::{verify_stripe_signature_with_tolerance, STRIPE_DEFAULT_TOLERANCE_SECS};
// Widen the window to 10 minutes:
verify_stripe_signature_with_tolerance(body, secret, header_value, 600);
// The default constant, if you want to reference it:
assert_eq!(300, STRIPE_DEFAULT_TOLERANCE_SECS);
```
Stripe may list multiple `v1=` entries in the header while a signing-secret rotation is in progress — verification succeeds if *any* entry matches, so you don't need to special-case rotation.
## In a handler
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::webhook::{verify_webhook_signature, WebhookProvider};
fn stripe_webhook(request: &Request) -> Response {
let signature = request
.get_header("Stripe-Signature".to_string())
.map(|h| h.value.to_string())
.unwrap_or_default();
if !verify_webhook_signature(WebhookProvider::Stripe, &request.body, b"whsec_...", &signature) {
let mut res = Response::new();
res.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
return res;
}
// Signature verified — safe to process request.body as a trusted Stripe event.
Response::new()
}
```
---
# Secrets Manager Integration
<https://rws8.tech/features/secrets/>
JWT signing keys, DB credentials, and TLS keys are ordinarily just plain environment variables. On a managed cloud, the idiomatic pattern is a managed secrets store — HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault — rather than an init container that bridges secrets into env vars by hand.
The `secrets` module gives any config value that ability directly: a value of the form `vault://path#field`, `aws-sm://name`, or `azkv://vault-name/secret-name` is resolved over HTTP(S) at startup. A value that doesn't match one of these prefixes passes through unchanged — this is purely additive to every existing `RWS_CONFIG_*`/`RWS_*` environment variable, not a breaking change.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["secrets"] }
```
```rust,no_run
use rust_web_server::secrets;
// VAULT_ADDR / VAULT_TOKEN must already be set in the environment.
let db_password = secrets::resolve("vault://secret/myapp/db#password")?;
# Ok::<(), rust_web_server::secrets::SecretsError>(())
```
| Prefix | Backend | Example |
|---|---|---|
| `vault://path#field` | HashiCorp Vault (KV v2) | `vault://secret/myapp/db#password` |
| `aws-sm://name` or `aws-sm://name#field` | AWS Secrets Manager | `aws-sm://prod/db-password` |
| `azkv://vault-name/secret-name` | Azure Key Vault | `azkv://my-kv/db-password` |
## Automatic env var resolution
The most common way to use this feature is to never call `secrets::resolve` directly at all. Once the `secrets` feature is enabled, `Server::setup()` calls `secrets::resolve_env_vars()` automatically at startup: it scans every currently-set environment variable whose name starts with `RWS_`, and rewrites in place any whose *value* matches one of the prefixes above.
That means any `RWS_CONFIG_*` value — `RWS_CONFIG_TLS_KEY_FILE`, a config-driven proxy route's `token_env`-referenced variable, anything — can be a secret reference with **no code changes**:
```bash
export VAULT_ADDR=https://vault.internal:8200
export VAULT_TOKEN=s.xxxxxxxxxxxxxxxx
export RWS_CONFIG_TLS_KEY_FILE="vault://secret/myapp/tls#key"
rws
```
`resolve_env_vars()` is also exported publicly for a library user driving their own startup sequence instead of calling `Server::setup()`.
## Fail fast, not fail open
A value that *looks* like a secret reference but fails to resolve — wrong token, unreachable backend, missing field — is a hard startup error, not a silently-ignored one. `Server::setup()` returns `Err`, and the binary's `.expect("server setup failed")` panics rather than starting:
```
thread 'main' panicked at src/main.rs:439:
server setup failed: "resolving secret reference for RWS_CONFIG_LOG_FORMAT: Vault request to
http://127.0.0.1:1/v1/secret/data/rws/log_format failed: connect to '127.0.0.1:1' failed:
Connection refused (os error 61)"
```
The alternative — silently falling back to the literal, unresolved string — would mean a server starting up with a JWT signing key or database password literally equal to `"vault://secret/myapp/db#password"`, which is worse than refusing to start at all.
## HashiCorp Vault (KV v2)
```bash
vault kv put secret/myapp/db password=hunter2
```
```rust,no_run
use rust_web_server::secrets;
let password = secrets::resolve("vault://secret/myapp/db#password")?;
# Ok::<(), rust_web_server::secrets::SecretsError>(())
```
`path` is the logical KV v2 path exactly as used by `vault kv get path` — the first segment is the mount name (`secret` above), and the rest is the secret's path within that mount. This mirrors the Vault CLI's own convention so you don't have to think in terms of the KV v2 HTTP API's own path shape (`/v1/{mount}/data/{path}`).
| Variable | Default |
|---|---|
| `VAULT_ADDR` | `http://127.0.0.1:8200` |
| `VAULT_TOKEN` | **(required)** |
Only token auth (a static `X-Vault-Token` header) is implemented — no AppRole, Kubernetes auth, or token renewal. A short-lived token minted by one of those methods can still be placed directly into `VAULT_TOKEN` by whatever process starts `rws`.
## AWS Secrets Manager
```rust,no_run
use rust_web_server::secrets;
// Whole secret string:
let password = secrets::resolve("aws-sm://prod/db-password")?;
// Secrets Manager conventionally stores {"username":"...","password":"..."}
// as one secret — #field parses SecretString as JSON and extracts one field:
let password = secrets::resolve("aws-sm://prod/db-creds#password")?;
# Ok::<(), rust_web_server::secrets::SecretsError>(())
```
This backend reuses the exact same AWS authentication story the [S3-compatible storage](/features/storage/) feature already implements — `storage-s3`'s SigV4 request signer and its IRSA/ECS/IMDSv2 workload-identity credential chain — rather than a second, separate copy to keep in sync.
| Variable | Default |
|---|---|
| `AWS_REGION` or `AWS_DEFAULT_REGION` | **(required)** |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | optional — falls back to workload identity when unset |
| `AWS_ENDPOINT_URL_SECRETSMANAGER` | `secretsmanager.{region}.amazonaws.com` |
When static keys are unset, credentials are auto-detected in the same order documented on the [storage page](/features/storage/#workload-identity-no-static-keys): EKS IRSA, then ECS task role, then EC2 IMDSv2 as a last resort.
`AWS_ENDPOINT_URL_SECRETSMANAGER` overrides the default host — this is the same environment variable name the official AWS SDKs use for LocalStack or VPC-endpoint overrides, reused here rather than an rws-specific name.
## Azure Key Vault
```rust,no_run
use rust_web_server::secrets;
let password = secrets::resolve("azkv://my-kv/db-password")?;
# Ok::<(), rust_web_server::secrets::SecretsError>(())
```
Two auth modes are tried in order:
1. **Service principal (client-credentials grant)** — used when all three of `AZURE_KEY_VAULT_TENANT_ID`, `AZURE_KEY_VAULT_CLIENT_ID`, and `AZURE_KEY_VAULT_CLIENT_SECRET` are set. This is a standalone Azure AD app registration, independent of the host's own identity.
2. **Managed Identity** — falls back to the exact same Managed Identity detection [Azure Blob Storage](/features/storage/#azure-blob-storage-storage-azure-feature) already implements: App Service/Container Apps (`IDENTITY_ENDPOINT` + `IDENTITY_HEADER`), or VM/AKS IMDS as a last resort — just requesting a token scoped to Key Vault's resource URI instead of Storage's.
| Variable | Purpose |
|---|---|
| `AZURE_KEY_VAULT_TENANT_ID` | Azure AD tenant ID (service principal mode) |
| `AZURE_KEY_VAULT_CLIENT_ID` | App registration client ID (service principal mode) |
| `AZURE_KEY_VAULT_CLIENT_SECRET` | App registration client secret (service principal mode) |
| `AZURE_KEY_VAULT_ENDPOINT_OVERRIDE` | Overrides `https://{vault-name}.vault.azure.net` — a Key Vault emulator, or tests |
| `AZURE_AD_LOGIN_ENDPOINT_OVERRIDE` | Overrides `https://login.microsoftonline.com` — tests only |
Then `GET https://{vault-name}.vault.azure.net/secrets/{secret-name}?api-version=7.4` with `Authorization: Bearer {token}`.
## No vendor SDK
Every HTTP call in this module goes through the crate's existing outbound `http_client::Client` — no AWS SDK, no Azure SDK, no Vault client library. JSON responses are parsed with the crate's hand-rolled `json_lite` parser (shared with `service_discovery`'s Consul/etcd backends), matching the same no-third-party-parsing philosophy applied everywhere else in this crate.
---
# Proxy Overview
<https://rws8.tech/proxy/overview/>
`rust-web-server` ships a full-featured proxy stack. You can use it in two distinct ways depending on how much control you need.
## Mode 1 — Config-driven proxy (no code)
Drop a `rws.config.toml` file next to the binary and run `rws`. As soon as the file contains at least one `[[route]]` or `[[upstream]]` section, the server automatically switches into proxy mode. No Rust code is required.
```toml
# rws.config.toml
[[upstream]]
name = "api"
backends = ["localhost:3000", "localhost:3001"]
[[route]]
name = "api-proxy"
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api"
```
```bash
rws # proxy mode activated automatically
```
**Best for:** ops teams, sidecar containers, API gateways, and situations where you just need to route traffic without touching Rust.
## Mode 2 — Library / programmatic
Add `rust-web-server` as a dependency and compose the proxy middleware stack in code.
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
use rust_web_server::server::Server;
let app = App::new()
.wrap(ReverseProxy::new(["http://backend:3000"])
.path_prefix("/api"));
let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, app); // async builds: Server::run_tls(listener, pool, app).await
```
**Best for:** services that need custom routing logic, per-request auth, conditional proxying, or tight integration with application code.
## Side-by-side comparison
| | Config-driven | Library |
|---|---|---|
| Requires Rust code | No | Yes |
| Hot-reload via SIGHUP | Yes (routes + upstreams) | No |
| Health checks | Yes (built-in) | Manual (`BackendPool` + `CircuitBreaker`) |
| L4 TCP / UDP / WS proxies | Yes (config sections) | Yes (standalone structs) |
| Path rewriting | Yes (`strip_path_prefix`, `add_path_prefix`) | Yes (`RewriteLayer`) |
| Per-route rate limiting | Yes | Yes (`RateLimitLayer`) |
| Auth | Yes (bearer token from env var, JWT, htpasswd basic) | Yes (`JwtLayer`, `BasicAuthLayer`) |
| Response caching | Yes (`[route.middleware.cache]`) | Yes (`CacheLayer`) |
| gRPC | Yes (`type = "grpc"`) | Yes (`GrpcProxy`, requires `http2` feature) |
| Canary / traffic splitting | No (planned) | Yes (`CanaryLayer`) |
| Service discovery | No (planned) | Yes (`BackendPool`) |
| Circuit breaker / retry | No (planned) | Yes (`CircuitBreaker`, `RetryLayer`) |
## Behind an L4 load balancer (PROXY protocol)
A TCP load balancer (HAProxy in `mode tcp`, AWS NLB, GCP's TCP proxy) hides
the client's address: rws sees the balancer's. With the PROXY protocol the
balancer prepends the client's address to each connection; turn it on in rws
(since 18.3.0):
```toml
proxy_protocol = true # RWS_CONFIG_PROXY_PROTOCOL
```
Every connection to the main listener must then start with a version 1 or
version 2 header — before the TLS handshake on HTTPS — and the address in it
is the client address: in access logs, rate limits, IP filters and
`X-Forwarded-For` to upstreams. A connection without a valid header within
10 seconds is closed, so only enable it when every client comes through the
balancer. The balancer's own health checks (v2 `LOCAL`, v1 `UNKNOWN`) keep
its address. The setting is read at startup.
```text
# HAProxy
backend rws
mode tcp
server rws1 10.0.0.5:443 send-proxy-v2 # or send-proxy for version 1
```
HTTP/3 (QUIC) has no PROXY protocol; it is unaffected.
## Detection logic
At startup `main()` calls `ProxyConfig::is_proxy_mode()`, which reads `rws.config.toml` (or the path in `RWS_CONFIG_FILE`) and returns `true` if the text contains `[[route]]` or `[[upstream]]`. When `true`, `build_from_file()` compiles the config into a `ConfigDrivenApp` that replaces the default `App`.
## What to read next
- [Config-Driven Proxy](/proxy/config-driven/) — full `rws.config.toml` reference
- [Reverse Proxy](/proxy/reverse-proxy/) — `ReverseProxy` middleware API
- [Load Balancing](/proxy/load-balancing/) — round-robin, random, IP-hash and least-connections
- [Health Checks](/proxy/health-checks/) — automatic backend health monitoring
- [Circuit Breaker](/proxy/circuit-breaker/) — failure isolation and retry
- [Canary](/proxy/canary/) — weighted traffic splitting
- [Service Discovery](/proxy/service-discovery/) — dynamic backend pools
- [TCP Proxy](/proxy/tcp-proxy/) — L4 TCP tunnel
- [UDP Proxy](/proxy/udp-proxy/) — datagram proxy
- [WebSocket Proxy](/proxy/websocket-proxy/) — WS upgrade tunneling
- [gRPC Proxy](/proxy/grpc-proxy/) — HTTP/2 gRPC forwarding
---
# Config-Driven Proxy
<https://rws8.tech/proxy/config-driven/>
When `rws.config.toml` (or the file pointed to by `RWS_CONFIG_FILE`) contains at least one `[[route]]` or `[[upstream]]` section, `rws` boots in proxy mode automatically. No code changes required.
## Minimal example
```toml
[[upstream]]
name = "api"
backends = ["localhost:3000"]
[[route]]
name = "api-proxy"
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api"
```
## `[[upstream]]` — backend pools
Each `[[upstream]]` block defines a named pool of HTTP or HTTPS backends.
```toml
[[upstream]]
name = "api" # required; referenced by route actions
backends = ["10.0.0.1:3000", "10.0.0.2:3000"] # plain HTTP (host:port)
strategy = "round_robin" # "round_robin" | "random" | "ip_hash" | "least_connections"
```
- `round_robin` (default) — cycles through live backends in order.
- `random` — picks a live backend at random on each request.
- `ip_hash` — hashes the client IP so the same client always reaches the same backend (useful for WebSocket/SSE stickiness without shared session state).
- `least_connections` — routes to whichever live backend currently has the fewest in-flight requests through this route.
An unrecognized or empty `strategy` value falls back to `round_robin`.
### Performance
A proxy route with no `[route.middleware]` and no global `[middleware]` is
forwarded without blocking a thread — HTTP and HTTPS upstreams alike,
including `tls_ca_file` ones: the request waits for the backend on its
connection's own task, over pooled keep-alive connections. Routes with
middleware use a thread from the blocking pool for the round trip — still
pooled, slower under heavy concurrency. See [Benchmarks](/reference/benchmarks/).
### TLS (HTTPS) upstreams
Prefix backend addresses with `https://` to connect over TLS. Certificate verification uses the WebPKI root store (same CA bundle as browsers). TLS connections are kept alive and reused, like plain ones — one handshake per connection, not per request — and large or chunked responses are streamed.
```toml
[[upstream]]
name = "secure-api"
backends = ["https://api.example.com:443", "https://api2.example.com:443"]
[upstream.health_check]
path = "/healthz"
interval_secs = 10
```
For internal services behind a private CA or a self-signed certificate, name
the CA to trust with `tls_ca_file`. It **replaces** the public roots for that
upstream — requests and health checks alike — so only certificates issued by
that CA are accepted:
```toml
[[upstream]]
name = "billing"
backends = ["https://billing.internal:8443"]
tls_ca_file = "/etc/rws/internal-ca.pem"
```
A CA file that can't be read fails closed: the upstream answers `502` and the
error is logged, rather than falling back to the public roots. `rws --check`
reports it. A reload re-reads the file, so a rotated CA takes effect on
`SIGHUP`.
:::note[Feature requirement]
HTTPS upstreams require the `http-client` or `http2` feature — both are included in the default (`http3`) build and the `http2`-only build. The `http1`-only build (`--no-default-features --features http1`) returns `502` for HTTPS backends and logs an error.
:::
### Backend URL format summary
| Format | Protocol | Default port |
|--------|----------|-------------|
| `host:port` | Plain TCP / HTTP | — (required) |
| `http://host:port` | Plain TCP / HTTP | 80 |
| `https://host:port` | TLS / HTTPS | 443 |
### `[upstream.health_check]` — automatic health monitoring
Add a nested `[upstream.health_check]` table to enable background health checking. Unhealthy backends are removed from the live rotation until they recover.
```toml
[[upstream]]
name = "api"
backends = ["10.0.0.1:3000", "10.0.0.2:3000", "10.0.0.3:3000"]
[upstream.health_check]
path = "/healthz" # GET path sent to each backend
interval_secs = 30 # how often to probe (default: 30)
timeout_ms = 5000 # connect + read timeout per probe (default: 5000)
healthy_threshold = 2 # consecutive successes before marking live (default: 2)
unhealthy_threshold = 3 # consecutive failures before marking dead (default: 3)
```
See [Health Checks](/proxy/health-checks/) for the full implementation details.
## `[[route]]` — routing rules
Routes are evaluated in declaration order; the first match wins.
```toml
[[route]]
name = "my-route" # informational label; no functional effect
```
### `[route.match]` — matching criteria
All fields are optional. Omitting a field means "match anything".
```toml
[route.match]
host = "api.example.com" # SNI hostname (TLS) or Host header (plain HTTP)
path = "/api/*" # prefix match when ending with *, exact match otherwise
method = "POST" # HTTP method (case-insensitive)
content_type = "application/json*" # Content-Type prefix match when ending with *
```
**Path matching rules:**
| Pattern | Matches |
|---------|---------|
| `/api/*` | Any path starting with `/api/` (prefix match) |
| `/api/ping` | Only `/api/ping` (exact match) |
| _(omitted)_ | All paths |
Paths are normalized before any route is matched, and sent upstream in that
form (since 17.113.1): percent-encoded unreserved characters are decoded
(`/%61dmin` → `/admin`; `%2F` and other escapes stay encoded), repeated
slashes collapse (`//admin` → `/admin`) and `.` / `..` segments are resolved
(`/x/../admin` → `/admin`). So a route that protects `/admin/*` can't be
skipped by spelling the path differently. `host` matches ignoring case, and
ignoring the port unless the route names one.
### `[route.action]` — what to do on match
The `type` field selects the action. An action's keys may also sit directly under `[route.action]` instead of in `[route.action.<type>]` (`type = "proxy"` plus `upstream = "api"` in the same table); if a key appears in both, the nested table wins.
#### `type = "proxy"` — forward to an upstream
```toml
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api" # upstream name defined in [[upstream]]
connect_timeout_ms = 5000 # TCP connect timeout in ms (default: 5000)
read_timeout_ms = 30000 # response read timeout in ms (default: 30000)
strip_path_prefix = "/api" # strip this prefix before forwarding (optional)
add_path_prefix = "/v2" # prepend this prefix before forwarding (optional)
```
#### `type = "grpc"` — forward gRPC to an HTTP/2 upstream
```toml
[route.action]
type = "grpc"
[route.action.grpc]
upstream = "grpc-svc"
connect_timeout_ms = 5000
read_timeout_ms = 30000 # per wait: a stream quiet for longer ends
```
Requests go over HTTP/2 (since 18.3.0; before, this action used HTTP/1.1 and
gRPC couldn't work): every call type streams and the `grpc-status` trailers
reach the client — see [gRPC proxy](/proxy/grpc-proxy/#streaming-and-trailers).
gRPC-web requests from browsers are translated to gRPC on the way
([gRPC-web](/proxy/grpc-proxy/#grpc-web-from-browsers)).
Upstream backends are `grpc://host:port` / `h2://host:port` (cleartext) or
`grpcs://host:port` / `h2s://host:port` (TLS, with the upstream's
`tls_ca_file` and `tls_client_cert_file` / `tls_client_key_file`).
#### `type = "redirect"` — HTTP redirect
```toml
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://example.com$path" # $path is replaced with the request URI
status = 301 # 301, 302, 307, or 308 (default: 301)
```
#### `type = "respond"` — fixed response
```toml
[route.action]
type = "respond"
[route.action.respond]
status = 200
body = "{\"status\":\"ok\"}"
content_type = "application/json"
```
#### `type = "static"` — serve a directory
```toml
[route.action]
type = "static"
[route.action.static]
root = "/var/www/site" # absolute or relative to the process working directory
index = ["index.html"] # tried in order for directory requests; defaults to ["index.html"]
```
Requests are resolved against `root`, independent of the server process's working
directory. Any request path containing a `..` segment (before or after
percent-decoding) is rejected with `403`; a request that resolves to a missing
file returns `404`. Directory requests (or any path resolving to a directory)
try each `index` entry in order and 404 if none exist. MIME type is detected
from the file extension, matching the built-in static-file controller.
### `[route.middleware]` — per-route middleware
Middleware is applied only to requests that match this route.
#### Rate limiting
```toml
[route.middleware.rate_limit]
max_requests = 100 # requests allowed per window (default: 1000)
window_secs = 60 # sliding window size in seconds (default: 60)
```
Any sub-table can also be written inline under `[route.middleware]` — `rate_limit = { max_requests = 100, window_secs = 60 }` is the same as the `[route.middleware.rate_limit]` table above.
#### Authentication
Three `type`s, all under `[route.middleware.auth]`:
**Bearer token** — always available, no extra feature:
```toml
[route.middleware.auth]
type = "bearer"
token_env = "API_TOKEN" # environment variable holding the expected token
```
Incoming requests must include `Authorization: Bearer <value of API_TOKEN>`. Returns `401 Unauthorized` on mismatch.
**JWT (HS256)** — requires the `auth` feature; wires into [`JwtLayer`](/features/auth/):
```toml
[route.middleware.auth]
type = "jwt"
secret_env = "JWT_SECRET" # environment variable holding the HS256 signing secret
```
**HTTP Basic (htpasswd file)** — requires the `auth` feature; wires into [`BasicAuthLayer::from_htpasswd_file`](/features/auth/#validating-against-an-htpasswd-style-file):
```toml
[route.middleware.auth]
type = "basic"
htpasswd_file = ".htpasswd"
```
The htpasswd file supports plain-text and `{SHA256}` (rws's own scheme) entries only — not Apache's real `{SHA}`/`$apr1$`/bcrypt. See the [auth feature page](/features/auth/#validating-against-an-htpasswd-style-file) for the exact file format and how to generate a `{SHA256}` entry with `openssl`.
:::note[Fails closed, not silently]
Auth fails closed. If `token_env`/`secret_env`/`htpasswd_file` is unset, empty, or unreadable — or the binary was built without the `auth` feature — that route answers `503 Service Unavailable` (logged to stderr at startup) instead of being served unprotected. Other routes keep working. Check your server's startup logs after changing `[route.middleware.auth]`.
:::
#### IP filter
```toml
[route.middleware.ip_filter]
allow = ["10.0.0.0/8", "192.168.1.100", "2001:db8::/32"] # allowlist (CIDR or exact, IPv4 or IPv6)
deny = ["1.2.3.4"] # denylist (ignored when allow is set)
```
#### Request rewriting
```toml
[[route.middleware.rewrite.request]]
type = "header_set"
name = "X-Real-IP"
value = "client"
[[route.middleware.rewrite.request]]
type = "uri_strip_prefix"
prefix = "/internal"
```
Request rule types: `header_set` (`name`, `value`), `header_remove` (`name`), `uri_set` (`value`), `uri_strip_prefix` / `strip_prefix` and `uri_add_prefix` / `add_prefix` (`prefix`, or `value`).
#### Response rewriting
```toml
[[route.middleware.rewrite.response]]
type = "header_set"
name = "Cache-Control"
value = "no-store"
[[route.middleware.rewrite.response]]
type = "body_replace"
from = "staging.internal"
to = "example.com"
[[route.middleware.rewrite.response]]
type = "status"
code = 410
reason = "Gone"
```
Response rule types: `header_set` (`name`, `value`), `header_remove` (`name`), `status` (`code`, `reason`), `body_replace` (`from`, `to`). An unknown `type` is ignored (and reported by `rws --check`).
#### Response caching
```toml
[route.middleware.cache]
ttl_secs = 60 # default: 60
vary_by = ["Accept-Language"] # request headers added to the cache key (optional)
```
Wraps the route in an in-memory [`CacheLayer`](/features/caching/) (up to 1000 entries). It is the innermost route middleware, so cached responses still pass through rewrite, auth, rate limit and IP filter.
#### Per-route timeout
```toml
[route.middleware]
timeout_ms = 5000 # 504 if this route (including its other middleware) doesn't respond within 5s
```
`0`/absent means no route-specific timeout. Bounds this route's *total* time — every other middleware layer runs inside it. See [Timeouts](/features/timeouts/) for the underlying mechanism and its one honest limitation (a synchronous handler that ignores its deadline keeps running in the background; only the client's wait is bounded).
#### Per-route max body size
```toml
[route.middleware]
max_body_size = 65536 # 413 if this route's request body exceeds 64 KiB
```
`0`/absent means no route-specific limit. This is a *stricter, additional* cap on top of the global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (see [Forms & Uploads](/building-apps/forms-uploads/#size-limits)) — not a substitute for it. It's checked after this route has already been matched, which means the body was necessarily already read in full to build the `Request` that route matching itself required; use it to keep one route (e.g. a small JSON API) stricter than the global ceiling a separate upload route needs, not as protection against unbounded memory use — the global limit is what already provides that, for every route.
The limit is the outermost layer of the route — checked before `timeout_ms`, auth and the rest — and a rejected request gets:
```text
HTTP/1.1 413 Payload Too Large
Content-Type: text/plain
request body of 100000 bytes exceeds this route's 65536-byte limit
```
## L4 proxy sections
### `[[tcp_proxy]]` — raw TCP tunnel
```toml
[[tcp_proxy]]
name = "pg"
listen = "0.0.0.0:5432"
backends = ["db-1:5432", "db-2:5432"]
connect_timeout_ms = 5000
```
### `[[udp_proxy]]` — UDP datagram proxy
```toml
[[udp_proxy]]
name = "dns"
listen = "0.0.0.0:53"
backends = ["8.8.8.8:53", "8.8.4.4:53"]
reply_timeout_ms = 2000
buffer_size = 65536
```
### `[[ws_proxy]]` — WebSocket proxy
```toml
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:8080"
backends = ["chat-backend:9000"]
connect_timeout_ms = 5000
read_timeout_ms = 30000
[ws_proxy.health_check] # optional — same keys as [upstream.health_check]
path = "/healthz"
```
L4 and WebSocket listeners are started once at boot; a `SIGHUP` reload does not add, remove or change them.
## Global middleware
Middleware that applies to every request goes in a top-level `[middleware]` section (same field structure as `[route.middleware]`). It wraps the whole router: matched routes *and* requests that fall through to the static files, health checks and metrics. Per-route middleware runs inside it.
```toml
[middleware.rate_limit]
max_requests = 500
window_secs = 60
```
## Using it from Rust
The `rws` binary wires this up automatically. An application embedding the crate can build the same proxy from the config file:
```rust
use rust_web_server::proxy_config::{build_from_file, ProxyConfig};
if ProxyConfig::is_proxy_mode() {
// `app` is a ConfigDrivenApp (Application + Clone); `_listeners` are the
// [[tcp_proxy]] / [[udp_proxy]] / [[ws_proxy]] threads.
let (app, _listeners) = build_from_file();
// pass `app` to Server::run / run_tls / run_quic
}
```
### Reloading without a restart
`SIGHUP` and `POST /admin/config/reload` reload `[[route]]`, `[[upstream]]` and `[middleware]` — see [Hot Reload](/features/hot-reload/#reloading-proxy-routes). An embedding application can drive the same reload itself, e.g. from its own admin endpoint or a file watcher:
```rust
use rust_web_server::proxy_config::{build_from_file, ReloadOutcome};
let (app, _listeners) = build_from_file();
let reloader = app.clone(); // clones share the live routes
// later, after rws.config.toml changed:
match reloader.reload_from_str(&std::fs::read_to_string("rws.config.toml")?) {
Ok(ReloadOutcome::Reloaded { routes, warnings }) => println!("{routes} routes live; {warnings:?}"),
Ok(ReloadOutcome::Unchanged) => {}
Err(report) => eprintln!("kept the running routes:\n{}", report.render("rws.config.toml")),
}
```
A file with errors never replaces the running routes. `[[tcp_proxy]]`, `[[udp_proxy]]` and `[[ws_proxy]]` listeners are bound once at startup; a reload that changes them returns a warning. To run your own code on every reload, register a hook with `config_reload::on_reload(|| ...)`.
## Full annotated example
```toml
# rws.config.toml — full proxy setup
# ── upstreams ──────────────────────────────────────────────────────────────────
[[upstream]]
name = "api"
backends = ["api-1:3000", "api-2:3000"]
strategy = "round_robin"
[upstream.health_check]
path = "/healthz"
interval_secs = 15
timeout_ms = 3000
healthy_threshold = 2
unhealthy_threshold = 3
[[upstream]]
name = "grpc-svc"
backends = ["grpc-1:50051"]
# ── routes ─────────────────────────────────────────────────────────────────────
[[route]]
name = "maintenance-page"
[route.match]
host = "down.example.com"
[route.action]
type = "respond"
[route.action.respond]
status = 503
body = "Under maintenance"
content_type = "text/plain"
[[route]]
name = "grpc"
[route.match]
content_type = "application/grpc*"
[route.action]
type = "grpc"
[route.action.grpc]
upstream = "grpc-svc"
[[route]]
name = "api"
[route.match]
path = "/api/*"
[route.action]
type = "proxy"
[route.action.proxy]
upstream = "api"
strip_path_prefix = "/api"
[route.middleware.rate_limit]
max_requests = 200
window_secs = 60
[route.middleware.auth]
type = "bearer"
token_env = "API_SECRET"
[[route]]
name = "redirect-www"
[route.match]
host = "example.com"
[route.action]
type = "redirect"
[route.action.redirect]
location = "https://www.example.com$path"
status = 301
# ── L4 proxies ─────────────────────────────────────────────────────────────────
[[tcp_proxy]]
name = "postgres"
listen = "0.0.0.0:5432"
backends = ["pg-primary:5432"]
[[udp_proxy]]
name = "dns"
listen = "0.0.0.0:53"
backends = ["8.8.8.8:53", "8.8.4.4:53"]
```
---
# Reverse Proxy
<https://rws8.tech/proxy/reverse-proxy/>
`ReverseProxy` is a [`Middleware`](/building-apps/middleware/) that forwards incoming HTTP/1.1 requests to one or more backends. It lives in `src/proxy/mod.rs` and requires no feature flags.
## Basic usage
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// All requests forwarded round-robin across two backends.
let app = App::new()
.wrap(ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"]));
```
## Selective proxying with `path_prefix`
By default every request is proxied. Use `.path_prefix()` to restrict proxying to a specific path prefix; all other requests fall through to the inner application.
```rust
// Only proxy /api/* — other paths are handled locally.
let app = App::new()
.wrap(ReverseProxy::new(["http://api-service:3000"])
.path_prefix("/api"));
```
## Load balancing strategy
`ReverseProxy` accepts a `.strategy()` builder for future extensibility. The only active strategy is round-robin.
```rust
use rust_web_server::proxy::{LoadBalancing, ReverseProxy};
let proxy = ReverseProxy::new(["http://a:8080", "http://b:8080"])
.strategy(LoadBalancing::RoundRobin); // default; explicit for clarity
```
The counter is a lock-free `AtomicUsize` incremented on every request. The backend index is `counter % backend_count`, giving a uniform cyclic distribution with no mutex overhead.
## Timeouts
```rust
let proxy = ReverseProxy::new(["http://backend:8080"])
.connect_timeout_ms(3_000) // TCP connect timeout (default: 5 000 ms)
.read_timeout_ms(60_000); // Response read timeout (default: 30 000 ms)
```
The write timeout for the forwarded request is always 10 seconds (not configurable via the builder today).
## Automatic failover
When a backend connection fails, `ReverseProxy` tries the next backend in round-robin order. Only after all backends have failed does it return `502 Bad Gateway`.
```
request → backend-1 fails → backend-2 fails → 502 Bad Gateway
request → backend-1 fails → backend-2 OK → response forwarded
```
## Headers
### Hop-by-hop headers stripped
The following headers are never forwarded to the upstream or back to the client, per RFC 7230:
- `Connection`
- `Keep-Alive`
- `Proxy-Authenticate`
- `Proxy-Authorization`
- `TE`
- `Trailer`
- `Proxy-Connection`
- `Transfer-Encoding`
- `Upgrade`
- any header named in the client's `Connection` header
- `Content-Length` (regenerated from the body that is actually sent)
### Headers added to forwarded requests
| Header | Value |
|--------|-------|
| `X-Forwarded-For` | Client IP from `ConnectionInfo`, appended to an incoming `X-Forwarded-For` (`203.0.113.7, 10.0.0.1`) — like nginx's `$proxy_add_x_forwarded_for`; only trust the entries your own proxies added |
| `Via` | `1.1 rws` |
| `Host` | Backend host (replaces the original `Host` header) |
| `Connection` | `keep-alive` (pooled path) or `close` (non-pooled callers) |
| `Content-Length` | Length of the forwarded body (exactly one) |
## Connection pooling
`ReverseProxy` ships with a built-in `ConnPool` that reuses idle TCP connections to backends (`https://` backends share a process-wide pool of TLS connections). When a backend responds with `Connection: keep-alive` and a fixed-size body (`Content-Length`), the stream is returned to the pool and reused for the next request to the same backend.
Streaming responses (SSE, chunked, large downloads) are forwarded directly to the client without buffering — see [Streaming responses](#streaming-responses).
**Defaults:** 64 idle connections per backend, 60-second idle timeout. No configuration needed.
### Tuning the built-in pool
```rust
use rust_web_server::proxy::ReverseProxy;
let proxy = ReverseProxy::new(["http://backend:8080"])
.max_idle_conns(32); // up to 32 idle connections per backend
```
### Sharing a pool across proxy instances
Useful when two `ReverseProxy` instances route to overlapping backends (e.g., an API proxy and an auth proxy both reach the same service).
```rust
use std::sync::Arc;
use std::time::Duration;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::{ConnPool, ReverseProxy};
let pool = Arc::new(ConnPool::new(32, Duration::from_secs(120)));
let api = App::new().wrap(
ReverseProxy::new(["http://api:3000"]).with_pool(Arc::clone(&pool)));
let auth = App::new().wrap(
ReverseProxy::new(["http://auth:4000"]).with_pool(Arc::clone(&pool)));
```
### How pooling works
| Backend response | Pool action |
|-----------------|-------------|
| `Connection: keep-alive` + `Content-Length` ≤ 1 MB | Stream returned to pool after body is read |
| `Transfer-Encoding: chunked` | Streamed to client (raw passthrough); connection not pooled |
| `Content-Type: text/event-stream` | Streamed to client (chunked re-encoding); connection not pooled |
| `Content-Length` > 1 MB | Streamed to client; connection not pooled |
| `Connection: close` or EOF | Stream dropped; TCP connection closed |
| Idle stream older than `idle_timeout` | Evicted on next `acquire()` |
| Pool at `max_idle` for this backend | Extra stream dropped (TCP closed) |
:::tip
Connection pooling is most impactful when backends and the proxy share a network (low-latency link). On high-throughput routes, pooling eliminates per-request TCP handshake latency entirely.
:::
## Backend URL format
Backend strings accept any of these forms:
```
https://host:port # TLS, verified against the WebPKI roots; port defaults to 443
http://host:port # scheme stripped, plain TCP
h2://host:port # treated as plain TCP
host:port # bare host:port
host # port defaults to 80
```
:::note[TLS to upstream]
`https://` backends are reached over TLS (rustls), with the certificate
verified against the WebPKI root store — the CA bundle browsers use. TLS
connections are pooled like plain ones, so the handshake is paid once per
connection, not per request. For a backend with a private-CA or
self-signed certificate, trust that CA instead of the public roots:
```rust
let proxy = ReverseProxy::new(["https://billing.internal:8443"])
.tls_ca_file("/etc/rws/internal-ca.pem")?; // PEM; replaces the WebPKI roots
```
A backend that requires a client certificate (mutual TLS) gets the one you
give it (since 18.3.0):
```rust
let proxy = ReverseProxy::new(["https://billing.internal:8443"])
.tls_client_cert("/etc/rws/client.pem", "/etc/rws/client.key")?; // PEM chain + private key
```
:::
## Streaming responses
`ReverseProxy` detects streaming backend responses and forwards bytes to the client as they arrive, without buffering the full body in memory. A response is streamed when any of these conditions are true:
| Condition | Use case |
|-----------|----------|
| `Content-Type: text/event-stream` | SSE / Server-Sent Events |
| `Transfer-Encoding: chunked` | AI token streams (OpenAI, Anthropic, etc.) |
| `Content-Length` > 1 MB | Large file downloads |
### How it works
1. The proxy reads only the response headers from the backend.
2. It creates a `Response` with the parsed headers and sets `Response::stream_pipe` to a reader backed by the live backend TCP connection.
3. The server detects `stream_pipe` and calls `Server::pipe_stream()`:
- **Chunked backend**: raw chunk frames are forwarded byte-for-byte (client handles decoding).
- **SSE / plain body**: bytes are re-encoded as `Transfer-Encoding: chunked` so the client receives each fragment immediately.
Streamed connections are never returned to the `ConnPool` — the backend socket is consumed by the pipe.
### SSE through the proxy
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// All SSE responses from the backend are forwarded without buffering.
let app = App::new()
.wrap(ReverseProxy::new(["http://event-service:9000"])
.path_prefix("/events"));
```
The client connects with `Accept: text/event-stream` as usual. The proxy transparently forwards each SSE event as a chunked frame the moment it arrives from the backend.
### AI token stream through the proxy
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// OpenAI / Anthropic APIs send Transfer-Encoding: chunked.
// The proxy forwards each token chunk without waiting for the full response.
let app = App::new()
.wrap(ReverseProxy::new(["https://api.openai.com"])
.path_prefix("/v1"));
```
### Custom stream_pipe (application code)
Any application handler can use `stream_pipe` to stream arbitrary `Read` sources:
```rust
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
fn my_handler(_req: &_, _params: &_, _conn: &_, _state: &_) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
// Any std::io::Read + Send value works: files, cursors, sockets, generators …
r.stream_pipe = Some(Box::new(std::io::Cursor::new(b"data: hello\n\n".to_vec())));
r
}
```
## 502 Bad Gateway
`ReverseProxy` returns `502 Bad Gateway` with `Content-Type: text/plain` when:
- No backends are configured.
- All backends fail to connect or return a network error.
## Combining with other middleware
Because `ReverseProxy` implements `Middleware`, you can stack it with rate limiting, auth, rewriting, and any other middleware via `.wrap()`.
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
use rust_web_server::rate_limit::RateLimitLayer;
let app = App::new()
.wrap(RateLimitLayer::new(100, 60)) // 100 req/min per IP
.wrap(ReverseProxy::new(["http://backend:3000"])
.path_prefix("/api"));
```
Middleware is applied outermost-first, so `RateLimitLayer` runs before `ReverseProxy`.
---
# Load Balancing
<https://rws8.tech/proxy/load-balancing/>
Every proxy component in `rust-web-server` — `ReverseProxy`, `H2ReverseProxy`, `GrpcProxy`, `TcpProxy`, `UdpProxy`, `WsProxy`, and `DynamicProxy` (config-driven mode) — uses the same underlying selection mechanism: a **lock-free atomic round-robin counter**.
## How round-robin works
Each proxy struct holds an `AtomicUsize` counter initialised to `0`. On every request the counter is incremented with `Ordering::Relaxed` and the backend index is computed as:
```rust
let idx = counter.fetch_add(1, Ordering::Relaxed) % backends.len();
```
No mutex is acquired; the operation is a single CPU instruction on every major architecture. Under concurrent load requests are distributed evenly across all live backends in cyclic order.
## Library API
```rust
use rust_web_server::proxy::{LoadBalancing, ReverseProxy};
// Explicit — same as the default.
let proxy = ReverseProxy::new(["http://a:8080", "http://b:8080", "http://c:8080"])
.strategy(LoadBalancing::RoundRobin);
```
The `.strategy()` method exists to allow future strategies to be added without a breaking API change.
## Config-driven mode
In config-driven mode, `[[upstream]]`'s `strategy` field selects between four strategies:
```toml
[[upstream]]
name = "api"
backends = ["api-1:3000", "api-2:3000", "api-3:3000"]
strategy = "least_connections" # "round_robin" (default) | "random" | "ip_hash" | "least_connections"
```
| Strategy | Behavior |
|---|---|
| `round_robin` (default) | The same lock-free atomic counter described above. |
| `random` | Uniform random selection, mixing a nanosecond timestamp with the round-robin counter — no new dependency. |
| `ip_hash` | Hashes the client IP (`DefaultHasher`) to pick a backend — the same client IP is consistently routed to the same backend as long as the live backend list doesn't change, giving basic session affinity without a session store. |
| `least_connections` | Routes to whichever live backend currently has the fewest in-flight requests, tracked via a `ConnectionGuard` (RAII, decrements on `Drop`) around each proxied request. |
An unrecognized or omitted `strategy` value falls back to `round_robin`.
### DynamicProxy and live backend lists
When `[upstream.health_check]` is configured, the health-checker thread maintains a shared `Arc>>` containing only the currently healthy backends. `DynamicProxy` (the config-driven proxy adapter) reads from this live list on every request:
```
All backends: [api-1, api-2, api-3]
↓ health checker removes api-2
Live backends: [api-1, api-3]
↓ round-robin counter % 2
Request N+0 → api-1
Request N+1 → api-3
Request N+2 → api-1
```
The live list is protected by `RwLock` — reads are concurrent; the health-checker thread takes a brief write lock once per probe round to publish the list.
## Failover behaviour
`ReverseProxy` tries backends in order starting from the round-robin cursor position. If a backend fails to connect, the next backend in the list is tried. This continues until either a backend succeeds or all backends have been tried (returning `502`).
```
backends = [A, B, C]
counter = 7 → idx 7 % 3 = 1 → start at B
B fails → try C → C succeeds → return response
```
## Canary / weighted round-robin
`CanaryLayer` implements weighted round-robin by pre-expanding the rotation: a backend with `weight = 3` appears three times in the rotation array. The same lock-free atomic counter selects slots.
```rust
// 3 out of 4 slots → stable; 1 out of 4 → canary.
CanaryLayer::new(vec![
WeightedBackend::new("http://stable:8080", 3),
WeightedBackend::new("http://canary:8080", 1),
])
```
See [Canary / Traffic Splitting](/proxy/canary/) for full details.
:::note[Strategy selection is config-driven-proxy only]
The library API (`ReverseProxy::strategy(LoadBalancing::RoundRobin)` above) only has the one variant today. `random`/`ip_hash`/`least_connections` are implemented in `DynamicProxy`, the adapter backing `rws.config.toml`'s `[[upstream]]` mode — see [Config-driven mode](#config-driven-mode) above.
:::
---
# Health Checks
<https://rws8.tech/proxy/health-checks/>
Health checks are available in **config-driven mode** only. Each `[[upstream]]` — and, for WebSocket backends, each `[[ws_proxy]]` — that includes a `[*.health_check]` section gets a dedicated daemon thread (`health-{name}`) that probes every backend on a regular interval and updates the live backend list without any restart. Both use the exact same checker (`start_health_checker`), so everything on this page applies to `[ws_proxy.health_check]` too, unless noted otherwise.
## Configuration
```toml
[[upstream]]
name = "api"
backends = ["api-1:3000", "api-2:3000", "api-3:3000"]
[upstream.health_check]
path = "/healthz" # GET path (default: "/health")
interval_secs = 15 # probe interval in seconds (default: 30)
timeout_ms = 3000 # connect + read timeout per probe (default: 5000)
healthy_threshold = 2 # consecutive successes to restore (default: 2)
unhealthy_threshold = 3 # consecutive failures to remove (default: 3)
```
Same fields for a WebSocket proxy:
```toml
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["wss://chat-a:8443", "wss://chat-b:8443"]
[ws_proxy.health_check]
path = "/healthz"
interval_secs = 10
timeout_ms = 2000
healthy_threshold = 2
unhealthy_threshold = 3
```
The probe is a plain HTTP `GET` against the backend's `host:port` — not a real WebSocket handshake — connected over TLS when the backend uses `wss://`. This is deliberate: nginx and Traefik health-check WebSocket upstreams the same way, because a WS backend is almost always a regular HTTP server with an upgrade route, not a WS-only listener, so a plain HTTP endpoint is enough signal that the process and its network stack are up.
## How it works
### Startup state
All backends start as **live**. The health checker assumes backends are healthy until proven otherwise.
### Probe request
Every `interval_secs` seconds the checker sends a minimal HTTP/1.1 request to each backend:
```
GET /healthz HTTP/1.1
Host: api-1
Connection: close
```
Both the TCP connect and the response read are bounded by `timeout_ms`. A backend is considered healthy if it replies with a `2xx` status code (the checker reads only the first 16 bytes of the response — just enough for `HTTP/1.1 2`).
### Failure tracking
Per-backend counters track consecutive successes and failures independently:
```
backend api-2:
consecutive failures = 1 → still live
consecutive failures = 2 → still live
consecutive failures = 3 → REMOVED from live list (unhealthy_threshold reached)
backend api-2 later:
consecutive successes = 1 → still dead
consecutive successes = 2 → RESTORED to live list (healthy_threshold reached)
```
The counters reset on state transition: a success resets the failure counter to `0`, and vice versa.
### Live list update
After probing all backends, the checker atomically replaces the shared live list:
```rust
Arc>> // written by health checker; read by DynamicProxy
```
`DynamicProxy` acquires a read lock on every request, which is concurrent-safe. The health checker acquires a write lock once per probe round, to publish the new list.
### Log output
State changes are logged to stderr:
```
[health] upstream=api backend=api-2:3000 removed (3x fail)
[health] upstream=api backend=api-2:3000 restored (2x ok)
```
## All backends unhealthy
If all backends fail their health checks, the live list becomes empty. `DynamicProxy` returns `502 Bad Gateway` for every HTTP/gRPC request until at least one backend recovers; a `[[ws_proxy]]` returns `503 Service Unavailable` for every new WebSocket upgrade attempt instead (the connection never gets far enough to be a "gateway" in the HTTP sense — it's rejected before any backend is contacted).
:::note[No health checks in library mode — except WsProxy]
`ReverseProxy` does not have built-in health checks. Use `BackendPool` with a `File` or `Dns` source (see [Service Discovery](/proxy/service-discovery/)) and `CircuitBreaker` (see [Circuit Breaker](/proxy/circuit-breaker/)) to replicate the behaviour programmatically.
`WsProxy` is the exception: `WsProxy::with_live_backends(all_backends, live)` takes an `Arc>>` you update from a health-check thread you write yourself — useful if you want a probe other than a plain HTTP `GET` (e.g. a real WebSocket handshake). See [WebSocket Proxy](/proxy/websocket-proxy/#health-checks).
:::
## Implementation reference
The health checker lives in `src/proxy_config/health.rs`:
- `start_health_checker(upstream_name, backends, live, config, stop, trust)` — spawns the daemon thread. It exits when `stop` is set (a `SIGHUP` reload sets it for the replaced configuration's checkers); `trust` is the upstream's `tls_ca_file` roots, if any, so probes to `https://` backends verify against the same CA as requests.
- `check_backend(backend, path, timeout, trust)` — sends a single probe, returns `true` on `2xx`.
- `parse_backend_url(backend)` — strips the `https://`/`http://`/`h2://`/`wss://`/`ws://` prefix (or none) and returns `(host, port, tls)`.
`WsProxy`'s side of this lives in `src/ws_proxy/mod.rs`:
- `WsProxy::with_live_backends(all_backends, live)` — the constructor `[ws_proxy.health_check]` uses internally, also public for library use.
- `pick_backend()` reads the current `live` list on every new connection and returns `None` (→ `503`) if it's empty.
---
# Circuit Breaker & Retry
<https://rws8.tech/proxy/circuit-breaker/>
The `circuit_breaker` module provides a per-backend state machine that stops forwarding requests to a broken upstream and a `RetryLayer` middleware that automatically re-dispatches failed requests.
## State machine
A `CircuitBreaker` tracks each backend independently and moves through three states:
```
Closed ──(threshold failures)──► Open ──(recovery_secs elapsed)──► HalfOpen
▲ │
└─────────────────────(probe succeeds)────────────────────────────────┘
(probe fails → back to Open)
```
| State | Behaviour |
|-------|-----------|
| `Closed` | All requests are forwarded. Failure counter is incremented on each error. |
| `Open` | All requests are rejected immediately — no TCP connection is attempted. Entered when consecutive failures reach `failure_threshold`. |
| `HalfOpen` | Up to `max_half_open_probes` (default 1) concurrent probe requests are let through after `recovery_secs` have elapsed; further concurrent requests are rejected like `Open` until an outcome resolves. Success closes the circuit; failure re-opens it and resets the recovery timer. |
## Creating a circuit breaker
```rust
use rust_web_server::circuit_breaker::CircuitBreaker;
// threshold=5 consecutive failures, recovery window=30 s
let cb = CircuitBreaker::new(5, 30);
// Optional: raise how many concurrent probes are let through while HalfOpen
// (default 1). Chainable — call before the breaker goes behind a Mutex/Arc.
let cb = CircuitBreaker::new(5, 30).max_half_open_probes(2);
```
:::note[Why cap HalfOpen probes at all]
Before this cap existed, *every* concurrent request arriving the instant a backend transitioned to `HalfOpen` saw `is_available() == true` — a burst of concurrent traffic could all count as "the" trial request at once, defeating the point of testing with a single probe. The cap (default 1) rejects anything beyond it, the same as `Open`, until `record_success`/`record_failure` resolves the in-flight probe(s) and resets the count to 0.
:::
### Global singleton
```rust
use rust_web_server::circuit_breaker;
// Returns a &'static Mutex (threshold=5, recovery=30 s)
let available = circuit_breaker::global()
.lock()
.unwrap()
.is_available("backend-a:8080");
```
`circuit_breaker::global()` is initialised once via `OnceLock` and shared across the entire process. Acquire the `Mutex` guard before calling any method.
## Methods
```rust
// Returns true if the request should be forwarded
cb.is_available("backend:8080");
// Record the outcome after a request completes
cb.record_success("backend:8080"); // HalfOpen → Closed, counter reset
cb.record_failure("backend:8080"); // increments counter or re-opens
// Inspect current state
cb.state("backend:8080"); // BreakerState::{Closed, Open, HalfOpen}
// Manually reset to Closed
cb.reset("backend:8080");
```
## Persistence (`RedisCircuitBreaker`)
`CircuitBreaker` keeps state in a plain in-process `HashMap`. A restart — or a rolling deploy — resets every backend back to `Closed`, so a backend that tripped the breaker moments before a restart looks healthy again immediately, and can cascade the same failures again before anything notices.
`RedisCircuitBreaker` has the exact same method names and the same Closed → Open → HalfOpen state machine, but persists each backend's state in Redis instead of an in-memory map:
```rust
use rust_web_server::circuit_breaker::RedisCircuitBreaker;
// threshold=5 consecutive failures, recovery window=30s — same as CircuitBreaker::new
let cb = RedisCircuitBreaker::new("127.0.0.1:6379", None, 5, 30);
// Or from RWS_REDIS_HOST/PORT/PASSWORD +
// RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD/RECOVERY_SECS/MAX_HALF_OPEN_PROBES:
let cb = RedisCircuitBreaker::from_env();
```
Every method is the same as `CircuitBreaker`'s, except each one is a Redis round trip and therefore returns `Result` — the network call can fail:
```rust
match cb.is_available("backend-a:8080") {
Ok(true) => { /* forward the request */ }
Ok(false) => { /* short-circuit — 503 without contacting the backend */ }
Err(e) => { /* Redis unreachable — you decide: fail open or fail closed */ }
}
cb.record_success("backend-a:8080")?;
cb.record_failure("backend-a:8080")?;
cb.state("backend-a:8080")?; // Resultcb.reset("backend-a:8080")?;
cb.set_limits(10, 60); // update thresholds on a live breaker, no restart needed
cb.set_max_half_open_probes(2); // HalfOpen probe cap (default 1)
```
Because state lives in Redis rather than in the struct, this also gets you circuit-sharing across every `rws` instance pointed at the same Redis server for free — not just restart survival, but a backend that trips the breaker on one instance is immediately seen as open by every other instance too.
:::note[Why Redis, not the model layer]
`DbPool` (the model layer) is `async fn`-only, while `CircuitBreaker`'s methods and `Middleware::handle` (what `RetryLayer` implements) are both synchronous. `RedisCircuitBreaker` reaches Redis over a plain blocking `TcpStream` (the same hand-rolled RESP client `RedisRateLimiter` and `RedisSessionStore` use), so it stays fully synchronous and drops into the same call sites `CircuitBreaker` already has, with no new Cargo dependency.
:::
:::caution[Not a distributed lock]
Each operation is a `GET` then a `SET` against one Redis key per backend — not a single atomic command. Two `rws` instances racing to record a failure for the same backend at the same instant can lose one of the two increments. This is intentional: unlike a rate limit (a hard resource/security boundary, where `RedisRateLimiter` uses genuinely atomic `INCR` for exactly this reason), a circuit breaker is a self-healing heuristic — opening one failure later than a perfectly-synchronized count would have has no real consequence.
:::
## RetryLayer middleware
`RetryLayer` wraps any `Application` and re-dispatches the request when the inner app returns a retryable status code (default: 502, 503, 504), up to `max_retries` additional attempts. The last response is returned as-is if all attempts are retryable.
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::circuit_breaker::RetryLayer;
use rust_web_server::middleware::WithMiddleware;
let app = WithMiddleware::new(App::new())
.wrap(RetryLayer::new().max_retries(2));
```
### Builder options
```rust
RetryLayer::new()
.max_retries(3) // default: 3
.retry_on(vec![502, 503, 504]) // default: [502, 503, 504]
```
### Custom retry codes
```rust
// Retry on 429 Too Many Requests and 503 Service Unavailable only
let retry = RetryLayer::new()
.max_retries(5)
.retry_on(vec![429, 503]);
```
## Using with ReverseProxy
Combine `RetryLayer` with `ReverseProxy` for resilient upstream calls. `RetryLayer` sits outside `ReverseProxy` in the middleware stack so that each retry attempt may land on a different backend via round-robin:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::{LoadBalancing, ReverseProxy};
use rust_web_server::circuit_breaker::RetryLayer;
let app = App::new()
.wrap(RetryLayer::new().max_retries(2))
.wrap(ReverseProxy::new([
"http://api-1:8080",
"http://api-2:8080",
"http://api-3:8080",
]));
```
:::note[Layer ordering]
Middleware runs in push order — first-pushed is outermost, last-pushed is innermost. Push `RetryLayer` first and `ReverseProxy` second so the retry layer wraps the proxy and re-runs it on a `502`/`503`/`504`; pushed the other way round, `ReverseProxy` answers before `RetryLayer` is ever reached.
:::
## Automatic ReverseProxy wiring
`RetryLayer` does not consult the `CircuitBreaker` automatically — it retries purely on status code, with no memory between requests. `ReverseProxy` does, via `.with_circuit_breaker(breaker)`: an `Open` backend is skipped with no TCP attempt at all, and `record_success`/`record_failure` is called automatically after every dial. No manual `is_available` checks around each proxied call needed:
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::circuit_breaker::global as global_breaker;
use rust_web_server::proxy::ReverseProxy;
let app = App::new().wrap(
ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"])
.with_circuit_breaker(Arc::new(global_breaker())),
);
```
`with_circuit_breaker` takes `Arc<dyn Breaker>` — a small object-safe trait implemented for both `Mutex` and `RedisCircuitBreaker`, so either kind plugs in through the same method:
```rust
use std::sync::{Arc, Mutex};
use rust_web_server::circuit_breaker::CircuitBreaker;
// A dedicated (non-global) in-memory breaker:
let breaker = Arc::new(Mutex::new(CircuitBreaker::new(5, 30)));
let app = App::new().wrap(
ReverseProxy::new(["http://backend-1:8080"]).with_circuit_breaker(breaker),
);
// Or a Redis-backed one:
use rust_web_server::circuit_breaker::RedisCircuitBreaker;
let breaker = Arc::new(RedisCircuitBreaker::from_env());
let app2 = App::new().wrap(
ReverseProxy::new(["http://backend-1:8080"]).with_circuit_breaker(breaker),
);
```
:::note[Fail open on Redis errors]
Through this integration, `RedisCircuitBreaker::is_available`'s `Err` (Redis unreachable) is treated as *available* — failing open, not closed. A breaker that can't be reached shouldn't become a new single point of failure for every proxied request. Call `RedisCircuitBreaker::is_available` directly (outside this integration) if your use case needs fail-closed semantics instead.
:::
No breaker is wired by default, so existing `ReverseProxy` code sees no behavior change unless it opts in.
If you're integrating a circuit breaker into your *own* custom controller or middleware instead of `ReverseProxy`, the manual pattern still works exactly as before:
```rust
use rust_web_server::circuit_breaker;
let backend = "api-service:8080";
let allowed = circuit_breaker::global()
.lock()
.unwrap()
.is_available(backend);
if !allowed {
// Return 503 without attempting a connection
}
```
## Metrics
`GET /metrics` includes `rws_circuit_breaker_state{backend}` automatically — no opt-in layer needed — for every backend known to `circuit_breaker::global()`:
```
# HELP rws_circuit_breaker_state Circuit breaker state per backend (0=closed, 1=half_open, 2=open)
# TYPE rws_circuit_breaker_state gauge
rws_circuit_breaker_state{backend="api-1:8080"} 0
rws_circuit_breaker_state{backend="api-2:8080"} 2
```
Wiring `ReverseProxy` to `circuit_breaker::global()` (as in the example above) gets you both the automatic proxy integration *and* this metric from the same breaker instance — the recommended default setup. `RedisCircuitBreaker` state isn't enumerable this way: the minimal hand-rolled RESP client has no `SCAN`/`KEYS` support to discover its keys.
---
# Canary Deployments
<https://rws8.tech/proxy/canary/>
`CanaryLayer` implements `Middleware` and distributes requests across backend URLs according to configurable weights. It is the primary tool for blue/green deployments, A/B testing, and incremental rollouts.
## How weights work
Each `WeightedBackend` entry carries a relative integer weight. Selection uses the same *smooth* weighted round-robin (SWRR) algorithm nginx uses: every entry accumulates a running `current_weight` by its configured weight on each request, the entry with the highest `current_weight` is picked, and that entry's `current_weight` is then reduced by the total weight. Weights `5, 1, 1` select roughly `A A B A C A A` (repeating) — **never** five `A`s in a row.
This matters because a naive approach — pre-expand the backend list so a `weight=5` backend appears five times, then advance a counter through it — *does* burst: five consecutive requests would all land on the same instance before rotating to the next. SWRR spreads the same 5:1:1 ratio evenly across the sequence instead.
A backend with `weight=0` is excluded from selection entirely — whether set at construction or via a live [`update()`](#live-weight-updates) call.
## Basic usage
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::canary::{CanaryLayer, WeightedBackend};
use rust_web_server::middleware::WithMiddleware;
// 75 % of traffic → stable, 25 % → canary
let app = WithMiddleware::new(App::new())
.wrap(
CanaryLayer::new(vec![
WeightedBackend::new("http://stable:8080", 3),
WeightedBackend::new("http://canary:8080", 1),
])
);
```
## Gradual rollout example
Start with a 90/10 split and shift traffic in stages as confidence grows. Since [`update()`](#live-weight-updates) changes weights without a restart, a real rollout script calls it in place rather than rebuilding the app between stages — shown here as three separate constructions only to make each stage's config explicit:
```rust
// Stage 1 — 90 % stable, 10 % v2
CanaryLayer::new(vec![
WeightedBackend::new("http://v1:3000", 9),
WeightedBackend::new("http://v2:3001", 1),
]);
// Stage 2 — 50/50
CanaryLayer::new(vec![
WeightedBackend::new("http://v1:3000", 1),
WeightedBackend::new("http://v2:3001", 1),
]);
// Stage 3 — full cut-over (weight=0 removes v1 from rotation)
CanaryLayer::new(vec![
WeightedBackend::new("http://v1:3000", 0),
WeightedBackend::new("http://v2:3001", 1),
]);
```
## Live weight updates
`CanaryLayer` is `Clone`, and every clone shares the same underlying state — keep a handle before wrapping the layer into an `Application`, and call `.update()` on it whenever traffic should shift:
```rust
use rust_web_server::canary::{CanaryLayer, WeightedBackend};
use rust_web_server::middleware::WithMiddleware;
let layer = CanaryLayer::new(vec![
WeightedBackend::new("http://v1:3000", 9),
WeightedBackend::new("http://v2:3001", 1),
]);
let handle = layer.clone(); // `layer` moves into `.wrap(...)` below
let app = WithMiddleware::new(App::new()).wrap(layer);
// From a rollout script, an admin endpoint, or a scheduled task — no restart:
handle.update(
vec![
WeightedBackend::new("http://v1:3000", 1),
WeightedBackend::new("http://v2:3001", 1),
],
vec![], // pools — empty here, see below
);
```
`.update(backends, pools)` fully replaces the configuration in one atomic swap — it's the runtime equivalent of building a fresh layer with `CanaryLayer::from_parts(backends, pools)`. Every existing clone of the layer (including one already wrapped into a running server) sees the new weights starting with its very next request.
## Dynamic backends via BackendPool
A group's members don't have to be one fixed URL — `WeightedPool` sources them from a [`BackendPool`](/proxy/service-discovery/) instead, so "10% of traffic to canary" keeps working as canary pods come and go, without editing the canary config each time:
```rust
use rust_web_server::canary::{CanaryLayer, WeightedBackend, WeightedPool};
use rust_web_server::service_discovery::BackendPool;
let canary_pool = BackendPool::dns("canary.internal", 8080).poll_interval_secs(15);
canary_pool.start();
// Fixed stable backend, dynamically-discovered canary group.
let app = App::new().wrap(
CanaryLayer::new(vec![WeightedBackend::new("http://stable:8080", 9)])
.add_pool(canary_pool, 1),
);
```
`.add_pool(pool, weight)` is a builder method — call it (repeatedly, for more than one dynamic group) before `.wrap(...)`. To build a layer purely from dynamic groups, use `CanaryLayer::with_pools(vec![WeightedPool::new(pool, weight), ...])` instead of `CanaryLayer::new(...)`.
The pool's *weight* controls how often that group is picked relative to other groups — which specific pool member answers is a plain round-robin over `pool.backends()` at selection time, independent of the cross-group SWRR sequence. `BackendPool` addresses are bare `"host:port"` strings with no scheme, so pool-sourced targets are always contacted over plain HTTP/1.1 — mix in a TLS `WeightedBackend` group alongside a pool if a fixed TLS endpoint is also needed.
## Scoping to a path prefix
Use `.path_prefix()` to canary-route only a subset of requests. Requests whose URI does not start with the prefix are passed through to the next middleware or the inner application:
```rust
CanaryLayer::new(vec![
WeightedBackend::new("http://stable:8080", 9),
WeightedBackend::new("http://canary:8080", 1),
])
.path_prefix("/api/v2")
```
## Timeout configuration
```rust
CanaryLayer::new(backends)
.connect_timeout_ms(2_000) // default: 5 000 ms
.read_timeout_ms(10_000) // default: 30 000 ms
```
## Failover behaviour
If the selected backend is unavailable, the layer falls through a ranked order — the primary SWRR pick first, then every other group ranked by its own (post-selection) weight state — until one succeeds or every candidate has failed, then returns `502 Bad Gateway`. This ranking is read-only: computing it doesn't perturb the shared SWRR state further, so a request that has to fail over several times doesn't skew the sequence subsequent unrelated requests see. A `WeightedPool` group expands into *all* of its current live members (starting from that group's own round-robin cursor) before falling through to the next group, so a request exhausts a chosen dynamic group fully before giving up on it.
## TLS backends
Use `https://`, `h2s://`, or `grpcs://` in a backend's URL to contact it over TLS instead of plain HTTP/1.1 — the default port becomes 443 instead of 80. This requires the `http-client` or `http2` feature (both pull in `rustls`); mix TLS and plain-HTTP backends freely in the same rotation:
```rust
CanaryLayer::new(vec![
WeightedBackend::new("http://stable-backend:8080", 9),
WeightedBackend::new("https://canary-backend:8443", 1),
])
```
Without the `http-client`/`http2` feature enabled, a TLS backend fails cleanly (the next backend in the rotation is tried, or `502 Bad Gateway` if none succeed) rather than hanging or panicking.
## WeightedBackend reference
```rust
// url can be "http://host:port", "https://host:port", "host:port" (plain,
// port 80), "h2://host:port", "h2s://host:port", "grpc://host:port", or
// "grpcs://host:port"
WeightedBackend::new(url: impl Into, weight: u32)
```
| Field | Type | Notes |
|-------|------|-------|
| `url` | `String` | Backend address; scheme determines plain vs. TLS and the default port (`http`/`h2`/`grpc` → 80, `https`/`h2s`/`grpcs` → 443); no scheme defaults to plain HTTP on port 80 |
| `weight` | `u32` | Relative share of traffic; `0` = excluded |
## WeightedPool reference
```rust
WeightedPool::new(pool: BackendPool, weight: u32)
```
| Field | Type | Notes |
|-------|------|-------|
| `pool` | `BackendPool` | Dynamic group — see [Service Discovery](/proxy/service-discovery/) for the available discovery sources |
| `weight` | `u32` | Relative share of traffic for the group as a whole; `0` = excluded |
---
# Service Discovery
<https://rws8.tech/proxy/service-discovery/>
`BackendPool` maintains a thread-safe list of `"host:port"` addresses and can refresh that list on a background thread. All clones of a `BackendPool` share the same underlying `RwLock>`, so the live backend list is always consistent across the process.
## Discovery sources
| Source | Constructor | Refresh |
|--------|-------------|---------|
| Fixed list | `BackendPool::static(...)` | Never |
| Environment variables | `BackendPool::env_prefix(...)` | On poll interval |
| File | `BackendPool::file(...)` | On poll interval |
| DNS A-record | `BackendPool::dns(...)` | On poll interval |
| DNS SRV record | `BackendPool::dns_srv(...)` | On poll interval |
| Consul health API | `BackendPool::consul(...)` | On poll interval |
| Docker container labels | `BackendPool::docker(...)` | On poll interval |
| etcd v3 watch | `BackendPool::etcd(...)` | Live — dedicated watch stream, not polling |
### Static list
```rust
use rust_web_server::service_discovery::BackendPool;
let pool = BackendPool::r#static(vec![
"10.0.0.1:8080".into(),
"10.0.0.2:8080".into(),
]);
// Immediately available — no .start() required
println!("{:?}", pool.backends());
```
### Environment variable prefix
Scans `PREFIX_0`, `PREFIX_1`, `PREFIX_2`, … in order, stopping at the first variable that is absent:
```rust
// Reads MY_SVC_BACKEND_0, MY_SVC_BACKEND_1, etc.
let pool = BackendPool::env_prefix("MY_SVC_BACKEND")
.poll_interval_secs(60);
pool.start();
```
Set the environment variables before starting:
```sh
export MY_SVC_BACKEND_0=api-1.internal:8080
export MY_SVC_BACKEND_1=api-2.internal:8080
```
### File-based discovery
One `host:port` per line. Blank lines and lines starting with `#` are ignored:
```text
# backends.txt
api-1.internal:8080
api-2.internal:8080
# api-3.internal:8080 <- temporarily disabled
```
```rust
let pool = BackendPool::file("backends.txt")
.poll_interval_secs(30);
pool.start();
```
### DNS A-record lookup
Resolves the hostname to all IP addresses returned by the OS (via `ToSocketAddrs`). Each IP becomes a `"ip:port"` entry:
```rust
// Resolves api.service.consul every 30 s
let pool = BackendPool::dns("api.service.consul", 8080)
.poll_interval_secs(30);
pool.start();
```
:::note[DNS caching]
The OS resolver may cache results independently of the poll interval. Set a low TTL on your DNS records and configure the OS resolver accordingly if you need fast propagation.
:::
### DNS SRV lookup (weighted DNS)
Resolves an SRV record (e.g. a Kubernetes headless Service's per-port record: `_http._tcp.my-svc.default.svc.cluster.local`) via a hand-rolled DNS query — no third-party DNS crate. Only the lowest-priority tier of answers is kept (RFC 2782: clients try that tier first); within it, each `target:port` is repeated `weight.clamp(1, 20)` times so a plain round-robin consumer still favors higher-weight targets proportionally — that repetition *is* "weighted DNS" here, since a flat `Vec` backend list has no other way to express weight:
```rust
let pool = BackendPool::dns_srv("_http._tcp.my-svc.default.svc.cluster.local")
.poll_interval_secs(15);
pool.start();
```
### Consul
Queries a Consul agent's `/v1/health/service/:name` endpoint, which already applies health-check filtering server-side (`passing=true`) — only instances passing every check are returned. `Service.Address` is preferred; if a service registered without one, falls back to `Node.Address`:
```rust
// addr is host:port of the Consul agent, e.g. a local agent or a sidecar.
let pool = BackendPool::consul("127.0.0.1:8500", "api")
.poll_interval_secs(10);
pool.start();
```
### Docker container labels
Queries the local Docker Engine API over its Unix domain socket (`/var/run/docker.sock` by default) for running containers carrying a given label, using the label's **value** as the backend address directly:
```rust
// A container run with: docker run -l rws.backend=10.0.0.5:8080 ...
let pool = BackendPool::docker("rws.backend")
.poll_interval_secs(10);
pool.start();
// Non-default socket path:
let pool = BackendPool::docker_with_socket("rws.backend", "/custom/docker.sock");
```
This is deliberately not "guess the address from published ports" — a container can be reachable via its bridge-network IP, a published host port, an overlay-network IP, or a sidecar, and there's no single correct answer without knowing the deployment topology. Requiring an explicit label value sidesteps that ambiguity entirely. Unix-only; on other platforms `discover()` logs a warning and returns an empty list.
### etcd v3 watch
Unlike every other source, `EtcdWatch` is not driven by the poll loop at all once `.start()` is called:
```rust
let pool = BackendPool::etcd(vec!["127.0.0.1:2379".into()], "/services/api/");
pool.start();
```
`.start()` still performs one synchronous `refresh()` first — a one-shot `/v3/kv/range` listing of the key prefix, so `pool.backends()` is populated immediately and the pool is fully usable even if you never call `.start()` at all. But instead of the generic sleep-and-poll thread, `.start()` spawns a dedicated thread holding a long-lived connection to etcd's gRPC-gateway `/v3/watch` endpoint, applying `PUT`/`DELETE` events to the backend list incrementally as they arrive — no polling interval after that. On disconnect it reconnects (with a short backoff) and re-lists from scratch before resuming the watch, so it never silently stops tracking changes.
Each key's value is used as the backend address directly (`etcdctl put /services/api/1 10.0.0.5:8080`, one key per instance under the shared prefix). Plain HTTP only — etcd deployments that require TLS need a plaintext sidecar/proxy in front for now.
## Starting background refresh
`.start()` performs an immediate synchronous refresh, then spawns a daemon thread that calls `.refresh()` every `poll_interval_secs` seconds. For `Static` sources `.start()` is a no-op.
```rust
pool.start(); // call once at application startup
```
## Reading the current backend list
```rust
let backends: Vec = pool.backends();
```
`backends()` takes a read lock and returns a snapshot. It never blocks for longer than the lock acquisition.
## Manual updates
For control planes that push backend lists externally:
```rust
pool.update(vec![
"new-backend-1:8080".into(),
"new-backend-2:8080".into(),
]);
```
## Integrating with ReverseProxy
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::service_discovery::BackendPool;
use rust_web_server::proxy::ReverseProxy;
let pool = BackendPool::dns("api.internal", 8080)
.poll_interval_secs(30);
pool.start();
// Snapshot the backend list at startup; wire the pool into a handler
// or refresh the proxy on each request for dynamic resolution.
let backends = pool.backends();
let app = App::new()
.wrap(ReverseProxy::new(backends));
```
:::note[Dynamic re-wiring]
`ReverseProxy` holds its backend list at construction time. For fully dynamic routing, use `CanaryLayer::with_pools(vec![WeightedPool::new(pool, 1)])` — it reads `pool.backends()` on every request (see [Canary](/proxy/canary/#dynamic-backends-via-backendpool)).
:::
## Poll interval
```rust
pool.poll_interval_secs(30) // default: 30 s; File, Dns, DnsSrv, Consul, and Docker sources only
```
The poll interval is set before `.start()`. Changing it after `.start()` has no effect on the running background thread. `EtcdWatch` ignores it entirely — after the initial `refresh()`, updates arrive from the live watch stream, not on a timer.
---
# TCP Proxy
<https://rws8.tech/proxy/tcp-proxy/>
`TcpProxy` is a Layer-4 reverse proxy. It accepts raw TCP connections and tunnels bytes bidirectionally to a backend server. Because it operates below the HTTP layer, it is protocol-agnostic: database wire formats, raw TLS passthrough, custom binary protocols, and any other TCP-based traffic all flow through unchanged.
## Basic usage
```rust
use rust_web_server::tcp_proxy::TcpProxy;
// Proxy raw TCP on port 5432 across two PostgreSQL backends
TcpProxy::new(["backend-1:5432", "backend-2:5432"])
.connect_timeout_ms(3_000)
.bind("0.0.0.0:5432")
.unwrap();
```
`.bind()` blocks the calling thread indefinitely. Run it in a dedicated thread or process.
## Round-robin selection
Backend selection uses an `AtomicUsize` counter that increments on every accepted connection. The counter wraps around the backend list, distributing connections evenly with no locking:
```rust
TcpProxy::new([
"db-primary:5432",
"db-replica-1:5432",
"db-replica-2:5432",
])
```
## Relay model
Each accepted connection spawns a new OS thread that calls `relay()`. Inside `relay()`, two more threads are spawned — one per direction — each running `std::io::copy` in a loop:
```
client ──► thread A: io::copy(client → backend) ──► backend
client ◄── thread B: io::copy(backend → client) ◄── backend
```
When either side closes its half of the connection (`shutdown(Write)`), the other direction drains and the relay thread exits cleanly.
## Timeouts and options
```rust
TcpProxy::new(backends)
.connect_timeout_ms(5_000) // TCP connect to backend (default: 5 000 ms)
```
There is no read timeout at the L4 level — idle connections are limited only by the OS TCP keepalive settings. Configure `net.ipv4.tcp_keepalive_*` on the host if you need to reclaim stuck connections.
## No TLS termination
`TcpProxy` forwards raw bytes. If the client sends a TLS `ClientHello`, that handshake is relayed to the backend as-is. The backend must speak TLS directly. For TLS termination before proxying, use an rws instance configured with `--tls-cert-file` in front of `TcpProxy`.
## Config-file usage
Activate `TcpProxy` from `rws.config.toml` using a `[[tcp_proxy]]` section:
```toml
[[tcp_proxy]]
name = "postgres"
listen = "0.0.0.0:5432"
backends = ["db-primary:5432", "db-replica:5432"]
connect_timeout_ms = 3000
```
Multiple `[[tcp_proxy]]` sections are supported — each starts a separate listener on its own thread.
## Use cases
| Protocol | Typical port |
|----------|-------------|
| PostgreSQL | 5432 |
| MySQL / MariaDB | 3306 |
| Redis | 6379 |
| SMTP | 25 / 587 |
| IMAP | 143 / 993 |
| Raw TLS passthrough | any |
| Custom binary RPC | any |
:::note[Connection count]
Each live connection holds two threads plus two socket file descriptors on the rws process. Size the thread pool and OS `ulimit -n` accordingly for high-connection workloads.
:::
---
# UDP Proxy
<https://rws8.tech/proxy/udp-proxy/>
`UdpProxy` receives UDP datagrams from clients and forwards each one to a backend server, then relays the backend's reply back to the original sender. This request-reply model covers protocols such as DNS, NTP, RADIUS, and syslog.
## Basic usage
```rust
use rust_web_server::udp_proxy::UdpProxy;
// Forward DNS queries round-robin across two resolvers
UdpProxy::new(["8.8.8.8:53", "8.8.4.4:53"])
.reply_timeout_ms(2_000)
.bind("0.0.0.0:53")
.unwrap();
```
`.bind()` blocks the calling thread indefinitely. Run it in a dedicated thread or process.
## Per-datagram thread model
Each received datagram is handled entirely in its own OS thread so the main receive loop is never blocked waiting for a backend reply:
1. Main loop calls `recv_from()` on the bound socket.
2. A new thread is spawned for each datagram.
3. The thread creates an ephemeral `UdpSocket` bound to `0.0.0.0:0`.
4. The thread sends the datagram to the selected backend.
5. The thread waits up to `reply_timeout` for a reply.
6. The reply is sent back to the original client address via the shared socket.
No session state is kept between datagrams. Each datagram is independent.
## Round-robin backend selection
An `AtomicUsize` counter selects backends without locking, cycling through the list evenly across datagrams:
```rust
UdpProxy::new([
"resolver-1:53",
"resolver-2:53",
"resolver-3:53",
])
```
## Timeouts and buffer size
```rust
UdpProxy::new(backends)
.reply_timeout_ms(5_000) // wait for backend reply (default: 5 000 ms)
.buffer_size(65_536) // per-datagram buffer bytes (default: 65 536)
```
If the backend does not reply within `reply_timeout_ms`, the datagram is silently dropped — the client receives no response for that query. This matches the behaviour of most UDP protocols, which implement their own retransmission logic.
## Config-file usage
Activate `UdpProxy` from `rws.config.toml` with a `[[udp_proxy]]` section:
```toml
[[udp_proxy]]
name = "dns"
listen = "0.0.0.0:53"
backends = ["dns1.internal:53", "dns2.internal:53"]
reply_timeout_ms = 500
```
Multiple `[[udp_proxy]]` sections are supported — each starts an independent listener.
## Use cases
| Protocol | Typical port |
|----------|-------------|
| DNS forwarding | 53 |
| NTP relay | 123 |
| syslog relay | 514 |
| SNMP proxying | 161 |
| RADIUS | 1812 / 1813 |
| DTLS passthrough | any |
:::note[No TCP fallback]
DNS responses larger than the buffer size (usually 512 bytes over plain UDP) may be truncated. The resolver will set the TC (truncation) bit; the client is then expected to retry over TCP. Use `TcpProxy` on port 53 alongside `UdpProxy` to handle both transports.
:::
---
# WebSocket Proxy
<https://rws8.tech/proxy/websocket-proxy/>
`WsProxy` listens for incoming HTTP/1.1 connections, validates that each connection is a WebSocket upgrade, forwards the upgrade handshake to a backend, and then tunnels all subsequent WebSocket frames bidirectionally.
## Basic usage
```rust
use rust_web_server::ws_proxy::WsProxy;
// Plain backends, round-robin
WsProxy::new(["ws://chat-backend:9000", "ws://chat-backend:9001"])
.connect_timeout_ms(3_000)
.bind("0.0.0.0:8080")
.unwrap();
// TLS backend (requires http-client or http2 feature)
WsProxy::new(["wss://chat.example.com"])
.bind("0.0.0.0:8080")
.unwrap();
```
`.bind()` blocks the calling thread indefinitely. Run it in a dedicated thread or process.
## Connection flow
For each incoming TCP connection, `WsProxy` follows this sequence:
1. **Read** the initial HTTP request (up to 8 192 bytes).
2. **Validate** that the request contains a WebSocket `Upgrade` header. Non-upgrade requests receive `400 Bad Request` and are closed.
3. **Connect** to the selected backend using the configured connect timeout.
4. **TLS handshake** (wss:// only) — establishes a `rustls` connection using the WebPKI root store.
5. **Forward** the HTTP upgrade request to the backend (rewriting the `Host` header).
6. **Verify** the backend responds with `HTTP/1.1 101 Switching Protocols`.
7. **Send** `101 Switching Protocols` to the client, completing the client-side handshake.
8. **Relay** all subsequent bytes bidirectionally — two threads for `ws://`, single-thread polling loop for `wss://`.
For plain backends, relay threads use `std::io::copy` and shut down their half of the connection when the remote side closes.
## Round-robin backend selection
An `AtomicUsize` counter cycles through backends evenly across connections with no locking:
```rust
WsProxy::new([
"ws-backend-1:9000",
"ws-backend-2:9000",
"ws-backend-3:9000",
])
```
`WsProxy::new(...)` treats this list as fixed and always live — round-robin cycles through every entry, healthy or not, exactly like before health checks existed. See [Health checks](#health-checks) below for the opt-in behavior where backends can drop out of (and back into) rotation.
## Health checks
By default a `WsProxy` has no health checker — every configured backend is always considered live, and a WebSocket upgrade request to a dead backend simply fails with a connect error. Two ways to make it health-check-aware:
**Config-driven proxy** — add `[ws_proxy.health_check]`, same fields as `[upstream.health_check]` (see [Health Checks](/proxy/health-checks/)):
```toml
[[ws_proxy]]
name = "chat"
listen = "0.0.0.0:9000"
backends = ["wss://chat-a:8443", "wss://chat-b:8443"]
[ws_proxy.health_check]
path = "/healthz" # plain HTTP GET, not a WebSocket handshake
interval_secs = 10
timeout_ms = 2000
healthy_threshold = 2
unhealthy_threshold = 3
```
`builder.rs` spawns the same background checker `[[upstream]]` pools use and shares its live-backend list with the `WsProxy` instance. A backend that fails `unhealthy_threshold` consecutive probes stops receiving new connections; it's restored after `healthy_threshold` consecutive successes. If every backend is currently unhealthy, new upgrade attempts get `503 Service Unavailable` instead of being routed to a backend known to be down.
**Library usage** — `WsProxy::with_live_backends(all_backends, live)` takes an `Arc>>` you update yourself, from any probe logic you want (e.g. a real WebSocket handshake instead of a plain HTTP `GET`):
```rust
use std::sync::{Arc, RwLock};
use rust_web_server::ws_proxy::WsProxy;
let all = vec!["ws://chat-a:9000".to_string(), "ws://chat-b:9000".to_string()];
let live = Arc::new(RwLock::new(all.clone()));
let checker_live = Arc::clone(&live);
std::thread::spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(10));
// probe each backend in `all` however you like, then:
// *checker_live.write().unwrap() = healthy_subset;
let _ = &checker_live;
});
WsProxy::with_live_backends(all, live)
.bind("0.0.0.0:8080")
.unwrap();
```
## Timeout configuration
```rust
WsProxy::new(backends)
.connect_timeout_ms(5_000) // TCP connect to backend (default: 5 000 ms)
.read_timeout_ms(30_000) // idle read timeout on client connections (default: 30 000 ms)
```
The read timeout applies to the initial HTTP upgrade read from the client. After the tunnel is established, there is no application-level read timeout — the connection stays open until either side closes it.
## Config-file usage
Activate `WsProxy` from `rws.config.toml` with a `[[ws_proxy]]` section:
```toml
# Plain TCP backends
[[ws_proxy]]
name = "chat-plain"
listen = "0.0.0.0:8080"
backends = ["ws://ws-backend-1:9000", "ws://ws-backend-2:9000"]
connect_timeout_ms = 3000
# TLS backend (requires http-client or http2 feature)
[[ws_proxy]]
name = "chat-tls"
listen = "0.0.0.0:8443"
backends = ["wss://chat.example.com"]
```
Multiple `[[ws_proxy]]` sections can run independently on separate ports.
## Backend URL schemes
`WsProxy` accepts three backend URL forms:
| Scheme | Transport | Default port |
|--------|-----------|--------------|
| `host:port` | plain TCP | (explicit) |
| `ws://host:port` | plain TCP | 80 |
| `wss://host:port` | TLS | 443 |
```rust
// Plain backends — all three forms are equivalent for port 9000:
WsProxy::new(["chat:9000", "ws://chat:9000"])
// TLS backends (port defaults to 443):
WsProxy::new(["wss://chat.example.com"])
WsProxy::new(["wss://chat.example.com:8443"])
```
`wss://` requires the `http-client` or `http2` Cargo feature (both include `rustls` + `webpki-roots`). Without these features, `wss://` backends return `502 Bad Gateway` with a clear error message.
## TLS relay internals
Plain `ws://` backends use two threads — one per direction — each running `std::io::copy`. This is the most efficient approach for plain TCP because `TcpStream::try_clone()` lets each thread independently own one direction.
`wss://` backends cannot use this pattern because `rustls::StreamOwned` cannot be cloned. Sharing it between two threads via `Arc>` would deadlock: the read thread holds the mutex while blocking for data, preventing the write thread from ever sending client data to the backend.
Instead, `wss://` uses a **single-thread polling relay**:
- Both streams are set to a 5 ms read timeout.
- The loop reads from the client, writes to the TLS backend; then reads from the TLS backend, writes to the client.
- When neither side has data, the loop sleeps 1 ms to avoid busy-waiting.
For interactive WebSocket applications (chat, games, collaborative editing), the maximum added latency from the polling interval is imperceptible.
## Inbound TLS (clients connecting over wss://)
The above covers TLS to the *upstream backend*. For TLS-terminated inbound connections (clients using `wss://` to reach the proxy itself), place an rws instance with `--tls-cert-file` in front:
```
wss:// client ──► rws (TLS termination, port 443) ──► WsProxy (plain, port 8080) ──► backend
```
## Use cases
- Chat servers and real-time messaging (Socket.IO, Phoenix Channels, Action Cable)
- Live data feeds and dashboards
- Multiplayer game backends
- Collaborative editing services
- Any service that upgrades HTTP to the WebSocket protocol
:::note[One backend per connection]
Backend selection happens once at connection time, not per-frame. All frames for a given WebSocket session are routed to the same backend for the lifetime of that connection.
:::
---
# gRPC Proxy
<https://rws8.tech/proxy/grpc-proxy/>
`GrpcProxy` is a middleware that forwards requests with `Content-Type: application/grpc*` to HTTP/2 backends, passing all other requests through to the next layer. It wraps `H2ReverseProxy` and adds a content-type filter so that gRPC and non-gRPC traffic can share the same application.
:::note[Feature requirement]
`GrpcProxy` and `H2ReverseProxy` require the `http2` Cargo feature (enabled by default in the `http3` build). Compile with `--features http2` or `--features http3` to include them.
:::
## Basic usage
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::GrpcProxy;
let app = App::new()
.wrap(GrpcProxy::new(["grpc-backend:50051"]));
```
Only requests whose `Content-Type` header starts with `application/grpc` are intercepted. All other requests fall through to the inner application.
## Content-type filter
`GrpcProxy` matches the following content types (prefix match):
| Value | Example |
|-------|---------|
| `application/grpc` | bare gRPC |
| `application/grpc+proto` | Protocol Buffers encoding |
| `application/grpc+json` | JSON-encoded gRPC |
| `application/grpc-web` | gRPC-Web (forwarded as is — put [`GrpcWebLayer`](#grpc-web-from-browsers) in front to translate it) |
Any request whose `Content-Type` does not start with `application/grpc` is passed to `next.execute()` unchanged.
## Scoping to a path prefix
Use `.path_prefix()` to further narrow which gRPC services are proxied:
```rust
use rust_web_server::proxy::GrpcProxy;
// Only proxy requests to the MyService gRPC service
GrpcProxy::new(["grpc-service:50051"])
.path_prefix("/svc.MyService")
```
Requests to other paths are passed through even if their content type is `application/grpc`.
## Round-robin load balancing
Backend selection uses an `AtomicUsize` counter that increments on every forwarded request. Multiple backends are tried in order if the first fails:
```rust
GrpcProxy::new([
"grpc-backend-1:50051",
"grpc-backend-2:50051",
"grpc-backend-3:50051",
])
```
## Combining gRPC and HTTP/2 traffic
Use `GrpcProxy` together with `H2ReverseProxy` to route mixed traffic on the same port:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::{GrpcProxy, H2ReverseProxy};
let app = App::new()
// gRPC traffic to the gRPC backend
.wrap(GrpcProxy::new(["grpc-svc:50051"]))
// All other HTTP/2 traffic to a separate backend
.wrap(H2ReverseProxy::new(["api-svc:8080"]).path_prefix("/api"));
```
## TLS backends (`grpcs://`)
Pass `grpcs://` (or `https://`) backend URLs to reach gRPC services that require TLS — every managed cloud gRPC endpoint (GCP, AWS, Azure) uses TLS:
```rust
GrpcProxy::new([
"grpcs://grpc-svc.prod.svc.cluster.local:443",
"grpcs://grpc-svc-2.prod.svc.cluster.local:443",
])
```
When no port is given, `grpcs://` defaults to 443. You can also write `https://` — both are treated identically.
| Scheme | Transport | Default port |
|--------|-----------|--------------|
| `host:port` | plain TCP | (explicit) |
| `grpc://host:port` | plain TCP | 80 |
| `grpcs://host:port` | TLS | 443 |
| `https://host:port` | TLS | 443 |
Certificates are verified against the WebPKI roots unless you trust a CA of
your own, and a backend that requires a client certificate (mutual TLS) gets
the one you configure (both since 18.3.0, also on `H2ReverseProxy` and
`ReverseProxy`):
```rust
GrpcProxy::new(["grpcs://billing.internal:50051"])
.tls_ca_file("/etc/rws/internal-ca.pem")? // trust this CA instead of the public roots
.tls_client_cert("/etc/rws/client.pem", "/etc/rws/client.key")?; // present this certificate
```
In `rws.config.toml` they are the upstream's `tls_ca_file`,
`tls_client_cert_file` and `tls_client_key_file`.
## Streaming and trailers
Since 18.3.0 every kind of gRPC call works through the proxy:
- **Unary and server streaming** — the response head is passed on as soon as
it arrives and each message as the backend sends it.
- **Client streaming and bidirectional** — the request body is forwarded as
it arrives, while the response streams back, so messages cross in both
directions on one call.
- **Status** — the backend's trailers (`grpc-status`, `grpc-message`,
`grpc-status-details-bin`) reach the client after the last message, over
HTTP/2 and HTTP/3; an error answered with headers only (trailers-only) keeps
its status too. `te: trailers` is forwarded, as gRPC servers require.
- **Errors** — a backend that fails mid-call resets the client's stream
(`INTERNAL_ERROR`), so the client sees an error rather than a truncated
success.
HTTP/1.1 has no trailers, so gRPC needs HTTP/2 or HTTP/3 between the client
and rws (any gRPC client negotiates it).
`.read_timeout_ms()` applies to each wait for the backend, so a stream that
goes quiet for longer than that (30 s by default) is ended with an error —
raise it for long-lived streams that can be idle.
## H2ReverseProxy
`GrpcProxy` is a thin filter on top of `H2ReverseProxy`. Use `H2ReverseProxy` directly when you want to proxy all HTTP/2 requests regardless of content type:
```rust
use rust_web_server::proxy::H2ReverseProxy;
// Plain TCP backend
let app = App::new()
.wrap(H2ReverseProxy::new(["h2://backend:8080"])
.connect_timeout_ms(3_000)
.read_timeout_ms(60_000));
// TLS backend (h2s:// or https://)
let app = App::new()
.wrap(H2ReverseProxy::new(["h2s://api.example.com:443"])
.connect_timeout_ms(3_000)
.read_timeout_ms(60_000));
```
`.read_timeout_ms()` (default 30 s) bounds each wait for the upstream — the TLS and HTTP/2 handshakes, the response head and every body frame — so a backend that goes silent gets a `502`, while a slow response that keeps sending isn't cut off.
Supported backend URL schemes for `H2ReverseProxy`:
| Scheme | Transport | Default port |
|--------|-----------|--------------|
| `host:port` | plain TCP | (explicit) |
| `h2://host:port` | plain TCP | 80 |
| `h2s://host:port` | TLS + ALPN `h2` | 443 |
| `https://host:port` | TLS + ALPN `h2` | 443 |
## gRPC-web from browsers
Browsers can't read HTTP trailers, so they speak gRPC-web: the status
travels at the end of the body. `GrpcWebLayer` (since 18.3.0) translates it
for the gRPC backend, as Envoy's `grpc_web` filter does — put it in front of
`GrpcProxy`:
```rust
use rust_web_server::proxy::{GrpcProxy, GrpcWebLayer};
let app = App::new()
.wrap(GrpcWebLayer::new())
.wrap(GrpcProxy::new(["grpc-svc:50051"]));
```
- `application/grpc-web[+proto|+json]` and `application/grpc-web-text`
(base64) requests go to the backend as `application/grpc…` with
`te: trailers`, the `-text` body decoded as it arrives.
- The response comes back as gRPC-web: the content type renamed, the
trailers (`grpc-status`, `grpc-message`) appended as the final frame of the
body (flag `0x80`), base64 for `-text`. Messages stream as the backend sends
them, over HTTP/1.1, HTTP/2 and HTTP/3.
- A trailers-only error keeps its status in the response headers.
- Plain gRPC requests pass through untouched.
The config file's `grpc` action does this on its own. A page on another
origin needs CORS: allow the `content-type`, `x-grpc-web` and
`x-user-agent` request headers and expose `grpc-status` and `grpc-message`
([CORS](/features/cors-security/)). CI checks it from headless Chromium
(`ci/e2e/grpc_web.sh`).
## Connections
Since 18.3.0 each backend gets one HTTP/2 connection, reused and multiplexed
by every request (up to the backend's stream limit) instead of a new
handshake per request; a connection the backend closes is replaced on the
next request. Requests are retried on another backend only before anything
was sent, and never when the request body was streaming.
The connections live on a small runtime of their own (two threads named
`rws-h2-upstream`), so the proxy works the same under the shared, per-core
or no tokio runtime (a test client).
Responses that aren't gRPC, up to 1 MiB and with a `content-length`, are
buffered (and keep their length); the rest stream.
---
# Database Overview
<https://rws8.tech/database/overview/>
The database layer in `rust-web-server` is a JPA/Hibernate-style async ORM backed by [`sqlx`](https://github.com/launchbadge/sqlx). All database operations are `async fn` and require a tokio runtime, which is automatically included when any `model-*` feature is enabled (they all imply `http2`).
## Feature flags
| Flag | Driver | Notes |
|---|---|---|
| `model-sqlite` | `sqlx/sqlite` | Best for development and embedded use; implies `http2` |
| `model-postgres` | `sqlx/postgres` | Standard PostgreSQL driver; implies `http2` |
| `model-mysql` | `sqlx/mysql` | MySQL and MariaDB; implies `http2` |
Add the flag to `Cargo.toml`:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["model-sqlite"] }
```
Unlike most feature pairs in this crate, `model-sqlite`, `model-postgres`, and `model-mysql` are **not** mutually exclusive — enable more than one and each gets its own [`DbPool`](#multiple-backends-in-one-binary) variant, so a single binary can hold pools to more than one backend (e.g. SQLite for hot-path data, PostgreSQL for an analytics tier).
## Environment variables
`DbConfig::from_env()` reads the following variables:
| Variable | Default | Notes |
|---|---|---|
| `RWS_DB_BACKEND` | Inferred if exactly one `model-*` feature is compiled in | `"sqlite"` / `"postgres"` / `"mysql"`; **required** if more than one `model-*` feature is compiled in |
| `RWS_DB_HOST` | `localhost` | Ignored for SQLite |
| `RWS_DB_PORT` | `5432` | Ignored for SQLite |
| `RWS_DB_USER` | — | Ignored for SQLite |
| `RWS_DB_PASSWORD` | — | Ignored for SQLite |
| `RWS_DB_NAME` | **required** | File path for SQLite; use `:memory:` for in-memory |
| `RWS_DB_POOL_SIZE` | `10` | Maximum number of connections in the pool |
## Quick start
The typical startup sequence is: read config, create pool, run migrations, then serve requests. All pool operations are `async fn`.
```rust
use rust_web_server::model::{DbConfig, DbPool, Value};
#[tokio::main]
async fn main() {
// 1. Build config from environment variables.
let config = DbConfig::from_env().expect("database config");
// 2. Create the async connection pool.
let pool = DbPool::new(config).await.expect("connection pool");
// 3. Run pending migrations.
pool.migrate("migrations/").await.expect("migrations");
// 4. Use the pool in request handlers (pool is Clone).
let users: Vec = pool
.query("SELECT * FROM users WHERE active = ?", &[Value::Bool(true)])
.await
.expect("query");
println!("active users: {}", users.len());
}
```
`DbPool` wraps a `sqlx::Pool` and is cheap to clone — pass it by value into your `AppWithState` state.
`DbPool::from_env().await` combines steps 1 and 2, and `DbPool::memory().await` (`model-sqlite`) opens an isolated in-memory SQLite pool — see [In-memory SQLite](/database/in-memory/).
## Manual config
If you prefer not to use environment variables, build `DbConfig` directly. `backend` selects which compiled-in driver this config is for — it only needs to be explicit when more than one `model-*` feature is enabled; with just one, `DbConfig::from_env()` (above) infers it for you, but a manually-built `DbConfig` always needs it stated:
```rust
use rust_web_server::model::{Backend, DbConfig};
let config = DbConfig {
backend: Backend::Postgres,
host: "localhost".into(),
port: 5432,
user: "app".into(),
password: "secret".into(),
database: "myapp".into(),
pool_size: 5,
};
```
For SQLite, only `database` (and `backend`) matter — `host`/`port`/`user`/`password` are ignored, so any placeholder value works:
```rust
let config = DbConfig {
backend: Backend::Sqlite,
database: "app.db".into(), // or ":memory:"
pool_size: 5,
host: String::new(),
port: 0,
user: String::new(),
password: String::new(),
};
```
## Multiple backends in one binary
Enable more than one `model-*` feature and each gets its own [`DbPool`](#pool-behaviour) variant — build one `DbConfig`/`DbPool` per backend, each with its own `backend`:
```toml
[dependencies]
rust-web-server = { version = "18", features = ["model-sqlite", "model-postgres"] }
```
```rust
use rust_web_server::model::{Backend, DbConfig, DbPool};
// Hot-path data in local SQLite:
let hot = DbPool::new(DbConfig {
backend: Backend::Sqlite,
database: "hot.db".into(),
pool_size: 5,
host: String::new(), port: 0, user: String::new(), password: String::new(),
}).await.expect("sqlite pool");
// Analytics tier in PostgreSQL:
let analytics = DbPool::new(DbConfig {
backend: Backend::Postgres,
host: "analytics-db.internal".into(),
port: 5432,
user: "app".into(),
password: "secret".into(),
database: "analytics".into(),
pool_size: 10,
}).await.expect("postgres pool");
```
Both pools are ordinary `DbPool`s — pass whichever one a given handler needs through your app state (e.g. as separate fields, or in a small struct: `struct Pools { hot: DbPool, analytics: DbPool }`). `pool.backend()` reports which backend a given `DbPool` is talking to, if you ever need to branch on it at runtime.
When reading config from the environment instead, set `RWS_DB_BACKEND` before each call to `DbConfig::from_env()` (or build two separate env namespaces and read them independently) — with more than one `model-*` feature compiled in, `from_env()` returns `Err` rather than guessing which backend you meant.
## Pool behaviour
`DbPool::new(config).await` creates a `sqlx::Pool` with `max_connections = config.pool_size` for whichever backend `config.backend` names. sqlx manages the connection lifecycle internally — connections are acquired on demand and returned to the pool automatically after each `await` point. There is no `get()`/`PooledConnection` API; just `pool.execute(...)` and `pool.query_rows(...)` directly.
Under the hood, `DbPool` is an enum with one variant per compiled-in backend (`Sqlite`/`Postgres`/`MySql`) — `execute`, `query_rows`, `begin`, and every other method dispatch to the matching variant internally, so calling code never needs to match on it itself unless it specifically wants to know which backend it's holding (`pool.backend()`).
## What's next
- [#[derive(Model)]](/database/model-derive/) — map structs to tables
- [Repository](/database/repository/) — CRUD operations
- [Query Builder](/database/query-builder/) — filtered queries
- [Pagination](/database/pagination/) — `Page` / `CursorPage`
- [Raw SQL](/database/raw-sql/) — full control
- [Transactions](/database/transactions/) — atomic operations
- [Migrations](/database/migrations/) — schema versioning
- [Relations](/database/relations/) — HasMany, HasOne, BelongsTo
- [In-memory SQLite](/database/in-memory/) — isolated databases for tests
---
# #[derive(Model)]
<https://rws8.tech/database/model-derive/>
`#[derive(Model)]` generates the full `Model` trait implementation for a struct, including typed serialisation, deserialisation, and helper constructors. It is the entry point for every other database feature.
## Minimal example
```rust
use rust_web_server::Model;
#[derive(Model, Debug, Clone)]
pub struct User {
#[primary_key(auto_increment)]
pub id: i64,
pub email: String,
pub active: bool,
}
```
The table name defaults to the lowercased struct name (`user`). The column names default to the field names.
## Struct attribute: `#[table(name = "...")]`
Override the table name:
```rust
#[derive(Model, Debug, Clone)]
#[table(name = "users")]
pub struct User {
#[primary_key(auto_increment)]
pub id: i64,
pub email: String,
pub active: bool,
}
```
## Field attributes
### `#[primary_key]`
Marks the field as the primary key. Exactly one field must carry this attribute; the derive macro returns a compile error if none does.
```rust
#[primary_key]
pub id: i64, // caller sets id before INSERT
```
Use this variant when you manage the primary key yourself. Note that `Repository::save` treats any key other than `0`/`NULL` as an UPDATE (and `ModelRepository` is keyed by `i64`), so insert rows with a caller-assigned key through raw SQL (`pool.execute`).
### `#[primary_key(auto_increment)]`
Marks the field as an auto-increment primary key. On INSERT the field is excluded from the column list; the database assigns the value. The generated row is re-fetched after the insert to populate the field.
```rust
#[primary_key(auto_increment)]
pub id: i64,
```
Backend behaviour:
- **SQLite** — uses `last_insert_rowid()`
- **PostgreSQL** — uses `RETURNING id`
- **MySQL** — uses `last_insert_id()`
### `#[column(name = "...")]`
Overrides the column name used in SQL. The field name in Rust can differ from the database column name.
```rust
#[column(name = "first_name")]
pub name: String,
```
### `#[column(unique)]`
Marks the column as unique. This is informational only — the derive macro does not create or alter the index. Use a migration to add the `UNIQUE` constraint in the schema.
```rust
#[column(unique)]
pub email: String,
```
### `#[ignore]`
Excludes the field from all database operations. On `from_row` the field receives `Default::default()`. On `to_values` the field is omitted entirely, so it never appears in INSERT or UPDATE statements.
```rust
#[ignore]
pub display_label: String, // computed at runtime, not stored
```
## Supported field types
| Rust type | `Value` variant |
|---|---|
| `i16`, `i32`, `i64` | `Value::Int(i64)` |
| `u32`, `u64` | `Value::Int(i64)` (cast) |
| `f32`, `f64` | `Value::Float(f64)` |
| `bool` | `Value::Bool(bool)` |
| `String` | `Value::Text(String)` |
| `Option` | `Value::Null` or inner type |
## Complete example
The following struct uses every attribute type:
```rust
use rust_web_server::Model;
#[derive(Model, Debug, Clone)]
#[table(name = "users")]
pub struct User {
/// Auto-assigned by the database.
#[primary_key(auto_increment)]
pub id: i64,
/// Maps to the `first_name` column.
#[column(name = "first_name")]
pub name: String,
/// Unique constraint enforced in the schema.
#[column(unique)]
pub email: String,
pub role: String,
pub active: bool,
pub score: f64,
/// Optional — stored as NULL when None.
pub bio: Option,
/// Not persisted; computed at runtime.
#[ignore]
pub display_label: String,
}
```
## What the macro generates
`#[derive(Model)]` expands to an `impl Model for User` block with these methods:
```rust
impl Model for User {
fn table_name() -> &'static str { "users" }
fn column_names() -> &'static [&'static str] {
&["id", "first_name", "email", "role", "active", "score", "bio"]
// "display_label" is absent because it is #[ignore]
}
fn primary_key_name() -> &'static str { "id" }
fn primary_key_value(&self) -> Value { self.id.to_column() }
fn primary_key_auto_increment() -> bool { true }
fn from_row(row: &ModelRow) -> Result {
Ok(User {
id: row.get("id")?,
name: row.get("first_name")?,
email: row.get("email")?,
role: row.get("role")?,
active: row.get("active")?,
score: row.get("score")?,
bio: row.get("bio")?,
display_label: Default::default(), // ignored field
})
}
fn to_values(&self) -> Vec<(&'static str, Value)> {
vec![
("id", self.id.to_column()),
("first_name", self.name.to_column()),
("email", self.email.to_column()),
("role", self.role.to_column()),
("active", self.active.to_column()),
("score", self.score.to_column()),
("bio", self.bio.to_column()),
// "display_label" is absent
]
}
}
```
In addition, two inherent methods are generated on the struct:
```rust
impl User {
/// Returns a ModelRepository bound to this pool.
pub fn repository(pool: &DbPool) -> ModelRepository;
/// Returns a QueryBuilder bound to this pool.
pub fn query(pool: &DbPool) -> QueryBuilder;
}
```
These are the primary entry points for [repository operations](/database/repository/) and [query building](/database/query-builder/).
Usage example:
```rust
use rust_web_server::model::{DbPool, Repository};
let pool = DbPool::from_env().await?;
// Save a new user
let user = User { id: 0, email: "alice@example.com".into(), active: true };
let repo = User::repository(&pool);
let user = repo.save(&user).await?; // returns the inserted row, id populated
// Query with the builder
let admins: Vec = User::query(&pool)
.where_eq("active", true)
.fetch_all().await?;
```
---
# Repository
<https://rws8.tech/database/repository/>
`ModelRepository` implements the `Repository` trait and provides the standard set of async create, read, update, and delete operations for a model type. Obtain one from any `#[derive(Model)]` struct using `User::repository(&pool)`.
## Obtaining a repository
```rust
use rust_web_server::model::DbPool;
let pool = DbPool::from_env().await?;
// User::repository is generated by #[derive(Model)]
let repo = User::repository(&pool);
```
The repository borrows the pool by reference for its lifetime — the pool itself is shared and async-safe.
## `Repository` trait
```rust
pub trait Repository {
async fn find_by_id(&self, id: ID) -> Result, DbError>;
async fn find_all(&self) -> Result, DbError>;
async fn save(&self, entity: &T) -> Result;
async fn save_all(&self, entities: &[T]) -> Result, DbError>;
async fn delete_by_id(&self, id: ID) -> Result<(), DbError>;
async fn delete_all_by_id(&self, ids: &[ID]) -> Result<(), DbError>;
async fn count(&self) -> Result<i64, DbError>;
async fn exists_by_id(&self, id: ID) -> Result<bool, DbError>;
}
```
The `ID` type is always `i64` for the built-in `ModelRepository` implementation.
## Method reference
### `find_by_id`
Executes `SELECT * FROM users WHERE id = ?` and returns the first matching row, or `None` if no row exists.
```rust
match repo.find_by_id(42).await? {
Some(user) => println!("found: {}", user.email),
None => println!("not found"),
}
```
### `find_all`
Executes `SELECT * FROM users` and returns every row in the table.
```rust
let all_users: Vec = repo.find_all().await?;
```
Use the [Query Builder](/database/query-builder/) when you need filtering, ordering, or pagination.
### `save`
Inserts or updates a single entity.
- **INSERT** — when the primary key field is `0` (or `Value::Null`).
- **UPDATE** — when the primary key is non-zero.
`save` returns the persisted entity with the primary key filled in (important for auto-increment inserts).
```rust
let saved = repo.save(&User {
id: 0, // 0 triggers INSERT with auto_increment
name: "Alice".into(),
email: "alice@example.com".into(),
role: "user".into(),
active: true,
}).await?;
println!("new id: {}", saved.id); // id assigned by the database
```
To update, set a non-zero primary key:
```rust
let updated = repo.save(&User { id: saved.id, role: "admin".into(), ..saved }).await?;
```
### `save_all`
Calls `save` for each entity in the slice, returning a `Vec` of the persisted entities.
```rust
let users = vec![
User { id: 0, name: "Bob".into(), email: "bob@example.com".into(), .. },
User { id: 0, name: "Carol".into(), email: "carol@example.com".into(), .. },
];
let saved = repo.save_all(&users).await?;
```
### `delete_by_id`
Executes `DELETE FROM users WHERE id = ?`.
```rust
repo.delete_by_id(42).await?;
```
### `delete_all_by_id`
Calls `delete_by_id` for each id in the slice (one DELETE per id).
```rust
repo.delete_all_by_id(&[1, 2, 3]).await?;
```
### `count`
Executes `SELECT COUNT(*) FROM users` and returns the result as `i64`.
```rust
let total: i64 = repo.count().await?;
println!("{total} users in database");
```
### `exists_by_id`
Returns `true` if a row with the given id exists.
```rust
if repo.exists_by_id(99).await? {
println!("user 99 exists");
}
```
Internally calls `find_by_id` and checks for `Some`.
## Auto-increment PK retrieval
After an INSERT, the newly assigned primary key is retrieved differently per backend:
| Backend | Mechanism |
|---|---|
| SQLite | `last_insert_rowid()` |
| PostgreSQL | `INSERT … RETURNING id` |
| MySQL | `last_insert_id()` |
In all cases, `save` re-fetches the inserted row via `SELECT * FROM … WHERE id = ?` and returns the complete, database-consistent entity.
## Full CRUD example
```rust
use rust_web_server::model::{DbPool, Repository};
use rust_web_server::Model;
#[derive(Model, Debug, Clone)]
#[table(name = "users")]
pub struct User {
#[primary_key(auto_increment)]
pub id: i64,
#[column(name = "first_name")]
pub name: String,
#[column(unique)]
pub email: String,
pub role: String,
pub active: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = DbPool::from_env().await?;
let repo = User::repository(&pool);
// CREATE
let alice = repo.save(&User {
id: 0,
name: "Alice".into(),
email: "alice@example.com".into(),
role: "user".into(),
active: true,
}).await?;
println!("created user #{}", alice.id);
// READ
let found = repo.find_by_id(alice.id).await?.expect("user exists");
println!("fetched: {} ({})", found.name, found.email);
// UPDATE
let promoted = repo.save(&User { role: "admin".into(), ..found }).await?;
println!("role is now: {}", promoted.role);
// EXISTS / COUNT
println!("exists: {}", repo.exists_by_id(alice.id).await?);
println!("total: {}", repo.count().await?);
// DELETE
repo.delete_by_id(alice.id).await?;
println!("deleted");
Ok(())
}
```
---
# Query Builder
<https://rws8.tech/database/query-builder/>
`QueryBuilder` is obtained from `T::query(&pool)` and provides a fluent API for building queries against the table mapped to `T`. All builder methods consume `self` and return a new `QueryBuilder`. Terminal methods (`fetch_all`, `fetch_one`, `count`, `delete`, `update`) are `async fn`.
## Obtaining a builder
```rust
use rust_web_server::model::DbPool;
let pool = DbPool::from_env().await?;
// QueryBuilder — tied to the `users` table
let qb = User::query(&pool);
```
## Filtering
### Equality filter
`where_eq(col, val)` adds a `col = ?` condition. The placeholder is `?` for SQLite and MySQL, `$N` for PostgreSQL — the builder handles the substitution automatically.
```rust
let admins: Vec = User::query(&pool)
.where_eq("role", "admin")
.fetch_all().await?;
```
Chain multiple calls to AND conditions together:
```rust
let result = User::query(&pool)
.where_eq("role", "admin")
.where_eq("active", true)
.fetch_all().await?;
```
### Raw filter
`filter(expr, params)` accepts a raw SQL fragment and a `Vec`. Use `?` as the placeholder regardless of backend — the builder converts to `$N` for PostgreSQL automatically.
```rust
use rust_web_server::model::Value;
let adults = User::query(&pool)
.filter("age >= ?", vec![Value::Int(18)])
.fetch_all().await?;
```
## Ordering
```rust
use rust_web_server::model::Order;
let recent = User::query(&pool)
.order_by("created_at", Order::Desc)
.fetch_all().await?;
let alphabetical = User::query(&pool)
.order_by("name", Order::Asc)
.fetch_all().await?;
```
## Pagination
`.limit(n)` and `.offset(n)` map directly to SQL `LIMIT` and `OFFSET`, for full manual control:
```rust
let page = 2u64;
let page_size = 20u64;
let users = User::query(&pool)
.order_by("id", Order::Asc)
.limit(page_size)
.offset((page - 1) * page_size)
.fetch_all().await?;
```
For most list endpoints, `.paginate(page, per_page)` (offset-based, with total counts) and `.paginate_after(cursor, per_page)` (cursor/keyset-based, for large tables) do the `COUNT(*)` + `LIMIT`/`OFFSET` bookkeeping above for you and return a `Page`/`CursorPage` — see [Pagination](/database/pagination/) for both, plus a built-in `Link` response header builder.
## Fetching results
| Method | SQL | Return type |
|---|---|---|
| `fetch_all().await` | `SELECT * FROM … WHERE … ORDER BY … LIMIT … OFFSET …` | `Result, DbError>` |
| `fetch_one().await` | same with `LIMIT 1` | `Result, DbError>` |
| `count().await` | `SELECT COUNT(*) FROM … WHERE …` | `Result<i64, DbError>` |
```rust
// all matching rows
let users: Vec = User::query(&pool)
.where_eq("active", true)
.fetch_all().await?;
// first match only
let user: Option = User::query(&pool)
.where_eq("email", "alice@example.com")
.fetch_one().await?;
// count without loading rows
let total: i64 = User::query(&pool)
.where_eq("role", "admin")
.count().await?;
```
## Mutation
### Delete matching rows
```rust
User::query(&pool)
.where_eq("active", false)
.delete().await?;
```
### Update a single column
`update(col, val).await` issues `UPDATE table SET col = ? WHERE …`. Combine with filters to scope the update.
```rust
User::query(&pool)
.where_eq("id", 42i64)
.update("role", "moderator").await?;
```
## Placeholder rules
The builder transparently converts between placeholder styles:
- **SQLite / MySQL** — `?`
- **PostgreSQL** — `$1`, `$2`, … (auto-numbered from left to right)
You never need to pick a style; write `?` in raw `.filter()` expressions and the builder takes care of the rest.
## Complete example: paginated list endpoint
```rust
use rust_web_server::header::Header;
use rust_web_server::model::{DbPool, Order, Page};
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use std::sync::Arc;
async fn list_users(pool: Arc, page: u64, per_page: u64) -> Response {
let per_page = per_page.min(100);
let page: Page = User::query(&pool)
.where_eq("active", true)
.order_by("created_at", Order::Desc)
.paginate(page, per_page).await
.unwrap_or_else(|_| Page::new(vec![], page, per_page, 0));
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
if let Some(link) = page.link_header("https://api.example.com/users") {
r.headers.push(Header::new("Link", link));
}
// serialize `page.items`, `page.total_items`, `page.total_pages` into the response body
r
}
```
See [Pagination](/database/pagination/) for `.paginate_after()` (cursor/keyset pagination) and more on the `Link` header.
:::note[Zero rows is not an error]
`fetch_all().await` returns an empty `Vec` when no rows match — it only returns `Err` on a real database error. Similarly, `fetch_one().await` returns `Ok(None)` for no match.
:::
---
# Pagination
<https://rws8.tech/database/pagination/>
`QueryBuilder` has two pagination terminals so list endpoints don't need to hand-roll `LIMIT`/`OFFSET` math or a separate `COUNT(*)` query every time: `.paginate()` for classic page-number pagination, and `.paginate_after()` for cursor (keyset) pagination on large or frequently-changing tables.
## Offset pagination — `.paginate(page, per_page)`
```rust
use rust_web_server::model::{Order, Page};
let page: Page = User::query(&pool)
.where_eq("active", true)
.order_by("created_at", Order::Desc)
.paginate(2, 20).await?;
page.items; // Vec — this page's rows
page.page; // 2
page.per_page; // 20
page.total_items; // total matching rows (COUNT(*) with the same filters)
page.total_pages; // ceil(total_items / per_page)
page.has_next();
page.has_prev();
```
`.paginate()` runs two queries — a `COUNT(*)` with the same filters (no limit/offset), and a `SELECT … LIMIT … OFFSET …` — then wraps both into a `Page`. Any `.limit()`/`.offset()` set earlier in the chain is overridden; `page`/`per_page` are authoritative. `page` and `per_page` are clamped to a minimum of `1`.
## Cursor (keyset) pagination — `.paginate_after(cursor, per_page)`
Offset pagination gets slower page by page on large tables, since the database still has to scan and discard every skipped row before the offset. Keyset pagination avoids that by filtering on "rows after the last one you saw," at the cost of losing `total_items`/`total_pages` and the ability to jump to an arbitrary page.
```rust
use rust_web_server::model::CursorPage;
// First page: no cursor
let page: CursorPage = User::query(&pool)
.where_eq("active", true)
.paginate_after(None, 20).await?;
page.items; // Vecpage.next_cursor; // Some("123") if there's a next page, None if this was the last
// Next page: pass the previous page's cursor back
let next: CursorPage = User::query(&pool)
.where_eq("active", true)
.paginate_after(page.next_cursor.as_deref(), 20).await?;
```
`.paginate_after()` orders by the primary key ascending — overriding any `.order_by()` set earlier, since keyset pagination requires ordering by the cursor column — and fetches `per_page + 1` rows in a single query to cheaply detect whether there's a next page, with no separate `COUNT(*)`. `next_cursor` is the last row's primary key, as a string; the primary key must be numeric, and `paginate_after` returns `Err` if a supplied cursor isn't a valid integer.
:::note[When to use which]
Use `.paginate()` for admin/dashboard UIs that show page numbers or a jump-to-page control. Use `.paginate_after()` for infinite-scroll feeds, high-volume APIs, or any table where frequent inserts make offset pagination's per-page scan cost (and the risk of skipping or repeating rows as data shifts underneath a multi-page fetch) a real problem.
:::
## `Link` response header (RFC 8288)
Both `Page` and `CursorPage` can build a standard `Link` header value, so a list endpoint can tell the client how to fetch the next/previous page without inventing its own pagination envelope:
```rust
use rust_web_server::response::Response;
use rust_web_server::header::Header;
let page: Page = User::query(&pool).paginate(2, 20).await?;
let mut response = Response::new();
if let Some(link) = page.link_header("https://api.example.com/users") {
response.headers.push(Header::new("Link", link));
}
// Link: <https://api.example.com/users?page=1&per_page=20>; rel="first",
// <https://api.example.com/users?page=1&per_page=20>; rel="prev",
// <https://api.example.com/users?page=3&per_page=20>; rel="next",
// <https://api.example.com/users?page=5&per_page=20>; rel="last"
```
`link_header` adds (or overwrites) `page`/`per_page` query parameters on the URL you pass in, preserving any other existing query parameters (e.g. `?active=true`). It omits `rel="first"`/`"prev"` on the first page and `rel="next"`/`"last"` on the last page, and returns `None` if there's only one page or the URL fails to parse.
`CursorPage::link_header(base_url, cursor_param)` builds a single `rel="next"` entry:
```rust
let page: CursorPage = User::query(&pool).paginate_after(None, 20).await?;
let link = page.link_header("https://api.example.com/users", "cursor");
// Some(r#"<https://api.example.com/users?cursor=142>; rel="next""#)
```
## Mapping to a DTO
`.map()` transforms `items` while keeping all pagination metadata — handy for converting a DB row type into an API response type right before serializing:
```rust
let page: Page = User::query(&pool).paginate(1, 20).await?;
let dto_page: Page = page.map(UserDto::from);
```
## Using outside the model layer
`Page` and `CursorPage` aren't tied to `QueryBuilder` or any `model-*` feature — construct one by hand if your data source is something else (an external API, an in-memory `Vec`):
```rust
use rust_web_server::pagination::Page;
let all_items = vec!["a", "b", "c", "d", "e"];
let page = Page::new(all_items[2..4].to_vec(), 2, 2, all_items.len() as u64);
```
---
# Raw SQL
<https://rws8.tech/database/raw-sql/>
The query builder covers most common queries, but sometimes you need raw SQL — complex joins, CTEs, window functions, or database-specific syntax. `DbPool` exposes three async methods for this.
## Typed query: `query::`
`pool.query::(sql, params).await` executes a SQL statement and deserialises each row into `T` using `T::from_row`. `T` must implement `Model` (typically via `#[derive(Model)]`).
```rust
use rust_web_server::model::{DbPool, Value};
let pool = DbPool::from_env().await?;
let users: Vec = pool.query::(
"SELECT * FROM users WHERE role = ? AND active = ?",
&[Value::Text("admin".into()), Value::Bool(true)],
).await?;
```
For PostgreSQL replace `?` with `$1`, `$2`, etc.:
```rust
let users: Vec = pool.query::(
"SELECT * FROM users WHERE role = $1 AND active = $2",
&[Value::Text("admin".into()), Value::Bool(true)],
).await?;
```
## Untyped query: `query_raw`
`pool.query_raw(sql, params).await` returns `Vec` — column-name/value pairs without deserialisation. Use this for ad-hoc queries, reporting, or when no matching `Model` type exists.
```rust
let rows = pool.query_raw(
"SELECT u.name, COUNT(p.id) AS post_count \
FROM users u \
LEFT JOIN posts p ON p.user_id = u.id \
GROUP BY u.id, u.name \
ORDER BY post_count DESC",
&[],
).await?;
for row in &rows {
let name: String = row.get::("name")?;
let count: i64 = row.get::<i64>("post_count")?;
println!("{name}: {count} posts");
}
```
## Execute: `execute`
`pool.execute(sql, params).await` runs INSERT, UPDATE, DELETE, or DDL statements and returns the number of rows affected.
```rust
let affected: u64 = pool.execute(
"UPDATE users SET last_login = ? WHERE id = ?",
&[Value::Text("2026-07-02T10:00:00Z".into()), Value::Int(42)],
).await?;
println!("{affected} row(s) updated");
```
## Extracting columns from `ModelRow`
`ModelRow::get::(col)` performs typed column extraction by name (case-insensitive). It returns `Result`.
```rust
let name: String = row.get::("name")?;
let age: i64 = row.get::<i64>("age")?;
let score: f64 = row.get::<f64>("score")?;
let verified: bool = row.get::<bool>("verified")?;
let bio: Option = row.get::>("bio")?;
```
## The `Value` enum
All parameters and raw column values are represented as `Value`:
| Variant | Rust type | Use for |
|---|---|---|
| `Value::Null` | — | SQL NULL |
| `Value::Bool(bool)` | `bool` | boolean columns |
| `Value::Int(i64)` | integer types | `INTEGER`, `BIGINT`, `SMALLINT` |
| `Value::Float(f64)` | float types | `REAL`, `DOUBLE PRECISION` |
| `Value::Text(String)` | `String` / `&str` | `TEXT`, `VARCHAR`, dates as strings |
| `Value::Bytes(Vec<u8>)` | `Vec<u8>` | `BLOB`, `BYTEA` |
```rust
use rust_web_server::model::Value;
let params = &[
Value::Text("alice@example.com".into()),
Value::Int(30),
Value::Bool(true),
Value::Null, // e.g. an optional field
];
```
## The `FromColumn` trait
Built-in `FromColumn` implementations cover `i16`, `i32`, `i64`, `u32`, `u64`, `f32`, `f64`, `bool`, `String`, and `Option` for any `T: FromColumn` (there is none for `Vec<u8>`; read a `Value::Bytes` column through a newtype with its own `FromColumn` impl).
Implement it for custom types to enable `row.get::("col")`:
```rust
use rust_web_server::model::{FromColumn, Value, DbError};
#[derive(Debug)]
pub enum UserRole { Admin, Member, Guest }
impl FromColumn for UserRole {
fn from_column(v: Value) -> Result {
match v {
Value::Text(s) => match s.as_str() {
"admin" => Ok(UserRole::Admin),
"member" => Ok(UserRole::Member),
"guest" => Ok(UserRole::Guest),
other => Err(DbError::new(format!("unknown role: {other}"))),
},
other => Err(DbError::new(format!("expected Text for UserRole, got {other:?}"))),
}
}
}
// Usage
let role: UserRole = row.get::("role")?;
```
## When to use raw SQL vs QueryBuilder
| Situation | Prefer |
|---|---|
| Simple equality filters, pagination, ordering | `QueryBuilder` |
| Multi-table joins | Raw SQL |
| CTEs (`WITH …`) | Raw SQL |
| Window functions (`OVER PARTITION BY …`) | Raw SQL |
| Database-specific functions (`DATE_TRUNC`, `JSON_EXTRACT`, …) | Raw SQL |
| INSERT / UPDATE / DELETE with complex expressions | Raw SQL via `execute` |
| Schema mutations (DDL) | Raw SQL via `execute` |
:::note[Parameterise always]
Never interpolate user input directly into a SQL string. Always pass untrusted values through the `params` slice so the driver can prepare and bind them safely.
:::
---
# Transactions
<https://rws8.tech/database/transactions/>
`DbPool` provides two ways to work with transactions: a closure-based helper that handles commit and rollback automatically, and a manual `begin` / `commit` / `rollback` API for more complex flows.
## Closure-based transactions
`pool.transaction(|mut tx| async move { … }).await` is the recommended approach. Pass an `async` closure that receives (and owns) a `DbTransaction`. `transaction` itself does not commit: call `tx.commit().await` before returning `Ok`. If the closure returns `Err` — or returns without committing — the transaction is dropped and rolled back automatically, so you never need to call `rollback()` yourself unless you want to roll back inside the closure explicitly.
```rust
use rust_web_server::model::{DbPool, Value};
let pool = DbPool::from_env().await?;
pool.transaction(|mut tx| async move {
tx.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
&[Value::Text("Alice".into()), Value::Text("alice@example.com".into())],
).await?;
tx.execute(
"INSERT INTO profiles (user_id, bio) VALUES (?, ?)",
&[Value::Int(1), Value::Text("Hello!".into())],
).await?;
tx.commit().await
}).await?;
```
The closure can return any `Ok(T)` value; `transaction` passes it back to the caller.
```rust
let new_id: i64 = pool.transaction(|mut tx| async move {
tx.execute(
"INSERT INTO orders (user_id, total) VALUES (?, ?)",
&[Value::Int(1), Value::Float(49.99)],
).await?;
// Retrieve the generated PK via a follow-up query
let rows = tx.query_rows("SELECT last_insert_rowid() AS id", &[]).await?;
let id: i64 = rows[0].get("id")?;
tx.commit().await?;
Ok(id)
}).await?;
```
## Manual transactions
Use `pool.begin().await` when you need explicit control — for example, when the commit/rollback decision is made outside a single closure scope.
```rust
let mut tx = pool.begin().await?;
let result = do_first_step(&mut tx).await;
if result.is_err() {
let _ = tx.rollback().await;
return Err(result.unwrap_err());
}
let result2 = do_second_step(&mut tx).await;
if result2.is_err() {
let _ = tx.rollback().await;
return Err(result2.unwrap_err());
}
tx.commit().await?;
```
## Inserting a User and Profile atomically
A common pattern is creating a parent record and a related child record together so that neither exists without the other.
```rust
use rust_web_server::model::{DbPool, Value};
let pool = DbPool::from_env().await?;
pool.transaction(|mut tx| async move {
// Insert the user
tx.execute(
"INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
&[
Value::Text("Bob".into()),
Value::Text("bob@example.com".into()),
Value::Bool(true),
],
).await?;
// Retrieve the generated PK
let rows = tx.query_rows("SELECT last_insert_rowid() AS id", &[]).await?;
let user_id: i64 = rows[0].get("id")?;
// Insert the profile linked to that user
tx.execute(
"INSERT INTO profiles (user_id, bio, avatar_url) VALUES (?, ?, ?)",
&[
Value::Int(user_id),
Value::Text("Software engineer".into()),
Value::Null, // optional field
],
).await?;
tx.commit().await?;
Ok(user_id)
}).await?;
```
If the profile insert fails (e.g., a constraint violation), the closure returns `Err` before `commit()`, the dropped transaction rolls back the user insert as well, and the database is left unchanged.
## Error handling pattern
Return `Err` from the closure to trigger an automatic rollback:
```rust
use rust_web_server::model::{DbError, Value};
pool.transaction(|mut tx| async move {
let affected = tx.execute(
"UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
&[Value::Float(100.0), Value::Int(sender_id), Value::Float(100.0)],
).await?;
if affected == 0 {
// Returning Err here causes an automatic rollback
return Err(DbError::new("insufficient funds or account not found"));
}
tx.execute(
"UPDATE accounts SET balance = balance + ? WHERE id = ?",
&[Value::Float(100.0), Value::Int(receiver_id)],
).await?;
tx.commit().await
}).await?;
```
:::note[Savepoints]
The current API does not expose SQL savepoints. For nested transaction semantics, use the closure form and handle all steps within a single `transaction` call.
:::
---
# Migrations
<https://rws8.tech/database/migrations/>
The migration runner reads `*.sql` files from a directory in lexicographic order, executes any that have not already been applied, and records each run in a `_schema_migrations` table. No external tools or frameworks are required.
## How it works
1. `pool.migrate("migrations/").await` creates `_schema_migrations(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)` if it does not already exist.
2. It reads every `*.sql` file in the directory sorted lexicographically by filename.
3. Files whose name is already in `_schema_migrations` are skipped.
4. Each unapplied file is executed inside a `BEGIN` / `COMMIT` transaction, one statement at a time — the file is split on `;`, so a `;` inside a string literal or a trigger/function body breaks it. If the file fails, the transaction is rolled back and `migrate` returns `Err` immediately — subsequent files are not attempted.
## File naming convention
Prefix files with a zero-padded sequence number so lexicographic order matches execution order:
```
migrations/
001_create_users.sql
002_add_email_index.sql
003_create_posts.sql
004_add_posts_status_column.sql
```
The full filename (without the directory prefix) is used as the version key, so renaming a file that has already been applied will cause it to run again. Never rename applied migration files.
## Example migration files
```sql
-- migrations/001_create_users.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
```
```sql
-- migrations/002_add_email_index.sql
CREATE INDEX idx_users_email ON users (email);
```
```sql
-- migrations/003_create_posts.sql
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL
);
```
```sql
-- migrations/004_add_posts_status_column.sql
ALTER TABLE posts ADD COLUMN published_at TEXT;
```
## Running migrations
Call `migrate` once at server startup, before the application begins accepting requests:
```rust
use rust_web_server::model::{DbConfig, DbPool};
#[tokio::main]
async fn main() {
let config = DbConfig::from_env().expect("database config");
let pool = DbPool::new(config).await.expect("connection pool");
pool.migrate("migrations/").await.expect("migrations failed");
// start the server ...
}
```
If all migrations have already been applied, `migrate` is a no-op and returns immediately.
## Checking migration status
`pool.migration_status(dir).await` returns a `Vec` — one entry per SQL file in the directory — without executing anything. Use it for health checks, admin endpoints, or CLI tooling.
```rust
use rust_web_server::model::MigrationStatus;
let statuses: Vec = pool.migration_status("migrations/").await?;
for s in &statuses {
let state = if s.applied { "applied" } else { "pending" };
println!("{}: {}", s.version, state);
}
// Check if any migrations are pending
let pending = statuses.iter().any(|s| !s.applied);
if pending {
eprintln!("Warning: unapplied migrations exist");
}
```
`MigrationStatus` fields:
| Field | Type | Description |
|---|---|---|
| `version` | `String` | Filename used as the version key |
| `applied` | `bool` | `true` if the migration has been run |
| `has_down` | `bool` | `true` if a companion `.down.sql` file exists, meaning this migration can be rolled back |
## Rolling back
Add a companion **down file** next to any up migration you want to be reversible: take the up file's name and replace its `.sql` extension with `.down.sql`.
```
migrations/
001_create_users.sql
001_create_users.down.sql
002_add_email_index.sql
002_add_email_index.down.sql
```
```sql
-- migrations/001_create_users.down.sql
DROP TABLE users;
```
```sql
-- migrations/002_add_email_index.down.sql
DROP INDEX idx_users_email;
```
`pool.rollback_last(dir).await` undoes the single most recently applied migration: it runs the down file's SQL and deletes the migration's row from `_schema_migrations`, both inside one transaction — rolled back together if the down SQL fails. "Most recently applied" is the highest version string among applied migrations, the same lexicographic order `migrate` uses to apply them.
```rust
use rust_web_server::model::DbPool;
let pool = DbPool::from_env().await.expect("connection pool");
match pool.rollback_last("migrations/").await {
Ok(Some(version)) => println!("rolled back {}", version),
Ok(None) => println!("nothing to roll back"),
Err(e) => eprintln!("rollback failed: {}", e),
}
```
`pool.rollback(dir, n).await` rolls back up to the last `n` applied migrations, most recent first, stopping early (without error) once nothing is left to undo:
```rust
// Roll back the last 3 migrations, in reverse order they were applied.
let rolled_back = pool.rollback("migrations/", 3).await.expect("rollback failed");
for version in &rolled_back {
println!("rolled back {}", version);
}
```
Both methods return `Err` if the migration they're trying to undo has no companion `.down.sql` file — rollback is opt-in per migration, not automatic. Migrations already rolled back before such a failure stay rolled back; each step commits independently.
:::caution[Down migrations are not automatically the inverse]
`rust-web-server` does not generate down SQL for you — you write it, the same way you write the up file. Get it right by hand: `DROP TABLE` undoes `CREATE TABLE`, `DROP COLUMN` undoes `ADD COLUMN`, and so on. A down file that doesn't actually reverse its up file will leave the schema in an inconsistent state relative to `_schema_migrations`.
:::
## Startup pattern
A typical async server startup sequence:
```rust
use rust_web_server::model::{DbConfig, DbPool};
use rust_web_server::app::App;
use rust_web_server::server::Server;
#[tokio::main]
async fn main() {
let config = DbConfig::from_env().expect("db config");
// Create pool and run migrations
let pool = DbPool::new(config).await.expect("connection pool");
pool.migrate("migrations/").await.expect("migrations");
// Pass the pool (Clone) into your app state
let app = App::with_async_state(std::sync::Arc::new(pool))
// register routes ...
;
let (listener, thread_pool) = Server::setup().expect("server setup");
Server::run_tls(listener, thread_pool, app).await;
}
```
:::note[One migration per file]
Each file should contain a single logical change. Splitting changes across many small files makes it easy to roll back a specific step and keeps the history readable.
:::
:::note[Idempotent DDL]
Prefer `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` so that rerunning a migration file manually (during development) does not error. The runner itself skips already-applied files, but the guard costs nothing and prevents accidents.
:::
---
# Relations
<https://rws8.tech/database/relations/>
The ORM provides three relationship helpers: `HasMany`, `HasOne`, and `BelongsTo`. Each holds the information needed to load related records on demand. There is no lazy loading — you call `.load(&pool).await` explicitly when you want the data.
Relation helpers are not fields of a model: `#[derive(Model)]` maps every field to a column, and a `HasMany` field has no column. Build the helper from a loaded record instead — a small method per relation keeps the call site readable.
## HasMany
`HasMany` represents a one-to-many relationship from an owner to a collection of child records. The child table has a foreign key column that holds the owner's primary key. Construct it with the owner's primary key value and the foreign key column name on the child table:
```rust
use rust_web_server::model::relation::HasMany;
use rust_web_server::model::Value;
use rust_web_server::Model;
#[derive(Model, Debug, Clone, Default)]
#[table(name = "users")]
pub struct User {
#[primary_key(auto_increment)]
pub id: i64,
pub name: String,
}
impl User {
pub fn posts(&self) -> HasMany {
HasMany::new(Value::Int(self.id), "user_id")
}
}
```
Load the related records by calling `.load(&pool).await`:
```rust
let posts: Vec = user.posts().load(&pool).await?;
```
Under the hood this issues: `SELECT * FROM posts WHERE user_id = ?`.
## HasOne
`HasOne` represents a one-to-one relationship where the child table holds the foreign key (the inverse of `BelongsTo`).
```rust
use rust_web_server::model::relation::HasOne;
impl User {
pub fn profile(&self) -> HasOne {
HasOne::new(Value::Int(self.id), "user_id")
}
}
// Loading
let profile: Option = user.profile().load(&pool).await?;
```
Under the hood: `SELECT * FROM profiles WHERE user_id = ? LIMIT 1`.
## BelongsTo
`BelongsTo` is the inverse side — the child record holds a foreign key that points to the owner's primary key.
```rust
use rust_web_server::model::relation::BelongsTo;
#[derive(Model, Debug, Clone, Default)]
#[table(name = "posts")]
pub struct Post {
#[primary_key(auto_increment)]
pub id: i64,
pub title: String,
pub user_id: i64,
}
impl Post {
pub fn user(&self) -> BelongsTo {
BelongsTo::new(Value::Int(self.user_id))
}
}
// Loading
let author: Option = post.user().load(&pool).await?;
```
Under the hood: `SELECT * FROM users WHERE id = ? LIMIT 1`.
## Complete User + Post example
```rust
use rust_web_server::model::{DbPool, Value};
let pool = DbPool::from_env().await?;
// Load a user
let user: User = pool
.query::("SELECT * FROM users WHERE id = ? LIMIT 1", &[Value::Int(1)])
.await?
.into_iter()
.next()
.expect("user 1");
// Load that user's posts — one extra query
let posts: Vec = user.posts().load(&pool).await?;
// Load the author of a post — one extra query
if let Some(post) = posts.first() {
let author: Option = post.user().load(&pool).await?;
}
```
## Avoiding N+1 queries
The explicit `.load().await` design makes N+1 queries visible — if you call `.load().await` inside a loop you will issue one query per iteration. The recommended pattern is to batch-load related records using `IN (…)`.
```rust
use rust_web_server::model::{DbPool, Value};
// Step 1 — load users (1 query)
let users: Vec = pool.query::("SELECT * FROM users WHERE active = ?", &[Value::Bool(true)]).await?;
// Step 2 — collect IDs
let user_ids: Vec = users.iter().map(|u| Value::Int(u.id)).collect();
// Step 3 — build IN clause and load all posts (1 query)
if !user_ids.is_empty() {
let placeholders = user_ids.iter().map(|_| "?").collect::>().join(", ");
let sql = format!("SELECT * FROM posts WHERE user_id IN ({})", placeholders);
let posts: Vec = pool.query::(&sql, &user_ids).await?;
// Step 4 — group in memory
use std::collections::HashMap;
let mut posts_by_user: HashMap<i64, Vec> = HashMap::new();
for post in posts {
posts_by_user.entry(post.user_id).or_default().push(post);
}
}
```
This loads N users and all their posts in exactly 2 queries regardless of how many users there are.
:::note[No lazy loading by design]
Lazy loading hides database access behind field access, making it easy to accidentally trigger hundreds of queries. Explicit `.load().await` calls keep all I/O visible at the call site, making performance characteristics clear during code review.
:::
---
# In-memory SQLite
<https://rws8.tech/database/in-memory/>
The `model-sqlite` feature ships an ergonomic constructor that opens a SQLite `":memory:"` database without a file path, hostname, or credentials. All operations are `async fn`.
## DbPool::memory() — isolated in-memory database
`DbPool::memory().await` creates a `sqlx::Pool` with `max_connections = 1` connected to `sqlite::memory:`. Each call returns a **separate, independent** in-memory database — ideal for tests that must not share state.
```rust
use rust_web_server::model::{DbPool, Value};
let pool = DbPool::memory().await.unwrap();
pool.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)",
&[],
).await.unwrap();
pool.execute(
"INSERT INTO items (name) VALUES (?)",
&[Value::Text("apple".into())],
).await.unwrap();
let rows = pool.query_rows("SELECT name FROM items", &[]).await.unwrap();
assert_eq!(1, rows.len());
let name: String = rows[0].get("name").unwrap();
assert_eq!("apple", name);
```
## Test isolation
Each `DbPool::memory().await` call is a new, empty database. Two pools have no shared state:
```rust
async fn test_db() -> DbPool {
let pool = DbPool::memory().await.unwrap();
pool.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", &[]).await.unwrap();
pool
}
// Test A
let pool_a = test_db().await;
pool_a.execute("INSERT INTO users (name) VALUES (?)", &[Value::Text("Alice".into())]).await.unwrap();
// Test B — completely isolated
let pool_b = test_db().await;
let rows = pool_b.query_rows("SELECT * FROM users", &[]).await.unwrap();
assert!(rows.is_empty()); // Alice is only in pool_a's database
```
Use `#[tokio::test]` for async tests:
```rust
#[tokio::test]
async fn test_example() {
let pool = DbPool::memory().await.unwrap();
pool.execute("CREATE TABLE t (v TEXT)", &[]).await.unwrap();
let rows = pool.query_rows("SELECT * FROM t", &[]).await.unwrap();
assert!(rows.is_empty());
}
```
## Feature requirement
`DbPool::memory()` requires the `model-sqlite` feature, which also implies `http2` (tokio runtime):
```toml
[dependencies]
rust-web-server = { version = "18", features = ["model-sqlite"] }
```
---
# MCP Server Overview
<https://rws8.tech/mcp/overview/>
The Model Context Protocol (MCP) is a JSON-RPC 2.0 standard that lets AI agents (Claude Desktop, Cursor, custom agents) call server-defined **tools**, read **resources**, and retrieve **prompt templates** over plain HTTP. `rust-web-server` ships a first-class `McpServer` that implements the MCP specification up to 2025-11-25 with no external dependencies, and speaks each version from 2024-11-05 on to the clients that ask for it — see [Protocol version negotiation](#protocol-version-negotiation) below.
## Creating an MCP server
```rust
use rust_web_server::server::Server;
use rust_web_server::mcp::{McpServer, McpContent, PromptMessage};
let mcp = McpServer::new("my-server", "1.0")
.tool(
"echo",
"Echo text back to the caller",
r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
|args| {
let text = rust_web_server::mcp::extract_arg(args, "text")
.unwrap_or_else(|| "(nothing)".to_string());
Ok(McpContent::text(text))
},
)
.resource(
"docs://{topic}",
"Documentation",
"Return documentation for a topic",
|uri| Ok(McpContent::text(format!("Docs for: {uri}"))),
)
.prompt(
"summarize",
"Summarize the given text",
|args| {
let text = rust_web_server::mcp::extract_arg(args, "text")
.unwrap_or_else(|| "some text".to_string());
Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
},
);
// Pass directly to the server — McpServer implements Application.
// let (listener, pool) = Server::setup().unwrap();
// Server::run(listener, pool, mcp);
```
## Attaching MCP to an existing app
If you already have routes, state, or middleware, use `.wrap()` so that non-MCP requests fall through to your existing `Application`:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::mcp::{McpContent};
let server = App::new()
.mcp("my-server", "1.0")
.tool("ping", "Ping the server", "{}", |_| Ok(McpContent::text("pong")))
.wrap(App::new()); // non-MCP requests handled by the built-in App
```
:::note[app.mcp() shorthand]
`App::new().mcp(name, version)` is equivalent to `McpServer::new(name, version)` with the built-in `App` automatically wired as the fallback. Both forms pass directly to `Server::run`.
:::
`AppWithState` and `WithMiddleware` have the same `.mcp()` method, so MCP can sit on top of your own routes — the dispatch chain is `McpServer` → your routes → the built-in controllers:
```rust
use rust_web_server::app::App;
use rust_web_server::mcp::McpContent;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::routes;
use rust_web_server::server::{ConnectionInfo, Server};
#[derive(Clone)]
struct State { version: &'static str }
fn get_version(_req: &Request, _p: &PathParams, _c: &ConnectionInfo, _s: &State) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r
}
let http = routes! {
App::with_state(State { version: "1.0.0" }),
GET "/api/version" => get_version,
};
let mut mcp = http.mcp("my-app", "1.0.0");
if let Ok(token) = std::env::var("MCP_TOKEN") {
mcp = mcp.require_bearer(token);
}
let app = mcp.tool(
"server_version",
"Return the running server version",
r#"{"type":"object"}"#,
|_| Ok(McpContent::json(r#"{"version":"1.0.0"}"#)),
);
let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, app);
```
## MCP endpoint
All JSON-RPC 2.0 messages travel over `POST /mcp`. The endpoint handles the full MCP lifecycle:
| JSON-RPC method | Purpose |
|----------------------|----------------------------------------------|
| `initialize` | Capability negotiation and server info |
| `ping` | Liveness check |
| `tools/list` | List all registered tools |
| `tools/call` | Invoke a tool by name |
| `resources/list` | List all registered resources |
| `resources/read` | Read a resource by URI |
| `prompts/list` | List all registered prompt templates |
| `prompts/get` | Retrieve a rendered prompt by name |
| `resources/subscribe` / `resources/unsubscribe` | Watch a resource URI — see [Resource subscriptions](#resource-subscriptions) |
| `logging/setLevel` | Set the minimum pushed log level — see [Logging](#logging) |
| `completion/complete` | Argument autocompletion — see [Argument autocompletion](#argument-autocompletion) |
`OPTIONS /mcp` is handled for CORS preflight, and `GET /mcp` opens an SSE stream for server → client push — see [SSE streaming transport](#sse-streaming-transport) below. All other HTTP methods return `405 Method Not Allowed`.
Override the default path with `.at("/custom-path")` if needed.
## Batch requests
`POST /mcp` also accepts a top-level JSON array instead of a single object — a JSON-RPC 2.0 batch request, letting a client send several calls in one HTTP round trip. Batching was removed from MCP in 2025-06-18, so batches are accepted only from clients on 2025-03-26 or earlier (by session, or `MCP-Protocol-Version`; no header and no session counts as 2025-03-26); others get a `-32600` error:
```json
// Request:
[{"jsonrpc":"2.0","method":"tools/list","id":1},
{"jsonrpc":"2.0","method":"ping","id":2}]
// Response — one entry per element, in order:
[{"jsonrpc":"2.0","result":{"tools":[...]},"id":1},
{"jsonrpc":"2.0","result":{},"id":2}]
```
Each element is dispatched through the same method table as a standalone request, and each one's success or error is independent — one element failing (e.g. an unknown method) doesn't affect the others or fail the batch as a whole.
Elements with no `id` (notifications) contribute no entry to the response array, exactly like a standalone notification produces no response body. A batch made up entirely of notifications returns `202 Accepted` with an empty body. An empty array (`[]`) is itself invalid per the JSON-RPC spec — it gets back a single `Invalid Request` error object rather than an empty `[]`.
:::note[initialize inside a batch]
If a batch includes a successful `initialize`, the *first* one mints a session and attaches `Mcp-Session-Id` to the overall response, same as a standalone `initialize` would. Sending more than one `initialize` in a single batch is unusual and only the first is honored for session purposes — one HTTP response can only carry one session id.
:::
## Pagination
`tools/list`, `resources/list`, and `prompts/list` return every registered item in one response by default. For a server with a lot of tools or resources, call `.page_size(n)` when building the server to cap each response to `n` items and enable cursor-based pagination:
```rust
use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0").page_size(50);
```
A response with more items remaining includes `"nextCursor"` — an opaque string the client echoes back as `params.cursor` on its next call to get the next page:
```json
// First call — no cursor:
{"method":"tools/list","params":{}}
// → {"result":{"tools":[...50 items...],"nextCursor":"NTA="}}
// Next call — cursor from the previous response:
{"method":"tools/list","params":{"cursor":"NTA="}}
// → {"result":{"tools":[...remaining items...]}} — no nextCursor once exhausted
```
Claude Desktop and other MCP clients already send `cursor` back automatically once a `nextCursor` appears in a response — no extra client-side wiring is needed.
:::note[The cursor is opaque]
The cursor is base64 of a decimal offset, but treat it as an opaque token — don't construct or parse it yourself. A malformed or tampered cursor gets a JSON-RPC `INVALID_PARAMS` (`-32602`) error rather than silently resetting to the first page. An offset past the end of the list returns an empty page with no `nextCursor`, not an error.
:::
## SSE streaming transport
The MCP Streamable HTTP spec defines a second transport alongside `POST /mcp`: a client that sends `GET /mcp` instead gets back a `text/event-stream` response that stays open indefinitely, for server → client push (log messages, progress updates, list-changed notifications, and anything else you want to push proactively).
Call `.notify(method, params_json)` from anywhere in your code — a background thread, a webhook handler, another tool's own handler — to push a JSON-RPC notification to every client currently connected to the SSE stream:
```rust
use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0");
// Elsewhere, e.g. after a background job finishes:
server.notify("notifications/message", Some(r#"{"level":"info","data":"job finished"}"#));
```
`params_json`, if given, must already be valid JSON (usually an object) — it's spliced into the notification verbatim, not escaped or re-serialized. `method` alone (no `id`) matches how the JSON-RPC spec defines a notification: fire-and-forget, no response expected.
```json
// What a connected client sees on the SSE stream after the call above:
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"job finished"}}
```
:::note[Backpressure and disconnection]
`.notify()` never blocks the calling thread. Each connected client has a bounded 32-frame buffer; a client that isn't reading fast enough (buffer full) is dropped from the broadcast list exactly like a disconnected one — one slow or stuck client can never stall notifications to everyone else. Idle connections receive a `: keep-alive` SSE comment every 15 seconds, both so intermediate proxies don't time out a silent connection and because it forces a write attempt that reveals a dead peer.
:::
The stream works over HTTP/1.1, HTTP/2 and HTTP/3 alike: each delivers
events as they are produced (tested against real HTTP/2 and HTTP/3 clients).
## Logging
:::caution[Deprecated by MCP 2026-07-28]
The logging capability (`logging/setLevel`, `notifications/message`, `.log()`) is deprecated in the [2026-07-28 MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/) (SEP-2577). It keeps working with clients that use it, for at least twelve months; new servers should log through their own logging instead.
:::
The spec lets a client request a minimum log level and receive server log messages pushed as `notifications/message` events over the SSE stream — useful during development, since an MCP client like Claude Desktop can show server diagnostics inline instead of you tailing a separate log file.
Call `.logging_enabled()` when building the server to advertise the `logging` capability in `initialize`, then call `.log(level, logger, data_json)` from anywhere in your code to push an entry:
```rust
use rust_web_server::mcp::{LogLevel, McpServer};
let server = McpServer::new("my-server", "1.0").logging_enabled();
// Elsewhere in your code, e.g. inside a tool handler or a background thread:
server.log(LogLevel::Warning, Some("database"), r#""connection pool exhausted""#);
```
`LogLevel` is the MCP spec's eight RFC 5424 syslog severities, from most to least severe: `Debug`, `Info`, `Notice`, `Warning`, `Error`, `Critical`, `Alert`, `Emergency`. A connected client can narrow which levels it wants delivered by calling `logging/setLevel`:
```json
{"method":"logging/setLevel","params":{"level":"warning"}}
```
After that call, only `.log()` calls at `Warning` or more severe are pushed to that client — `Debug`, `Info`, and `Notice` calls are silently filtered. Before any client calls `logging/setLevel`, the default minimum is `Debug` — the least restrictive level, so nothing is filtered until a client asks for less noise.
`logger` (optional) identifies the log's source — a module name, a subsystem, whatever's meaningful in your app — and appears as the `logger` field on the pushed notification. `data_json` must already be valid JSON (an object, a string, a number — the spec allows any type) and is spliced in verbatim.
:::note[`.logging_enabled()` doesn't gate functionality]
Calling `.logging_enabled()` only changes what's *advertised* in `initialize`'s `capabilities` — `.log()` and `logging/setLevel` both work whether or not it was called. A spec-honest client simply wouldn't send `logging/setLevel` in the first place without seeing the capability advertised, so pairing them is expected but not enforced.
:::
`.log()` is built directly on [`.notify()`](#sse-streaming-transport) and inherits its behavior: it never blocks the calling thread, and a client whose event buffer fills up is dropped from the broadcast list exactly like a disconnected one.
## Dynamic registration
`.tool()`, `.resource()`, and `.prompt()` are consuming builders — call them once while constructing the server, before it starts serving requests. For tools/resources/prompts that only become known later (a plugin discovered at startup, a database connection, a hot-reloaded config file), use the `&self` equivalents instead, callable at any time from any thread holding a clone of the server:
```rust
use rust_web_server::mcp::{McpContent, McpServer};
let server = McpServer::new("my-server", "1.0");
// Later, from any thread holding a clone of `server`:
server.register_tool("refresh_cache", "Reload the in-memory cache", "{}", |_args| {
Ok(McpContent::text("cache refreshed"))
});
let existed = server.remove_tool("refresh_cache"); // -> true
```
The matching pairs are `.register_tool(...)`/`.remove_tool(name)`, `.register_resource(...)`/`.remove_resource(uri_template)`, and `.register_prompt(...)`/`.remove_prompt(name)` — each `remove_*` returns `bool` (whether something was actually found and removed).
This works because tools/resources/prompts are stored behind `Arc>>` rather than a plain list, so every clone of `McpServer` — each connection gets one — shares the same live data. A tool handler is looked up and its `Arc` cloned out from under a brief read-lock before being called, so a slow-running tool never blocks a concurrent registration on another thread.
Every registration or removal that actually changes something pushes the corresponding notification to every `GET /mcp` SSE client:
```json
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}
```
(Similarly `notifications/resources/list_changed` and `notifications/prompts/list_changed` — none of these carry a `params` field, per spec.) A removal that finds nothing (an unknown name) pushes no notification.
`initialize` now advertises `"listChanged":true` for tools, resources, and prompts unconditionally — dynamic registration is always available, unlike logging which needs `.logging_enabled()`. `resources.subscribe` is advertised as `true` too — see [Resource subscriptions](#resource-subscriptions).
:::note[No dynamic equivalent for every builder variant]
`.register_tool()` and `.register_prompt()` only cover the plain shapes — matching `.tool()` and `.prompt()`. There's no dynamic equivalent of `.tool_with_context()`, `.tool_annotated()`, or `.prompt_with_args()`. To change a dynamically-added tool's annotations or a prompt's argument definitions, remove it and register it again under the same name.
:::
## Argument autocompletion
Clients like Cursor and VS Code call `completion/complete` to offer autocomplete suggestions while the user fills in a tool or prompt argument, instead of leaving it a plain text box. Register a provider with `.completion(ref_type, ref_name, handler)`:
```rust
use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0")
.completion("tool", "deploy", |arg_name, partial| {
match arg_name {
"region" => Ok(vec!["us-east-1", "eu-west-1", "ap-southeast-1"]
.into_iter()
.filter(|r| r.starts_with(partial))
.map(String::from)
.collect()),
_ => Ok(vec![]),
}
});
```
`ref_type` is `"tool"` or `"prompt"` — matched against the request's `ref.type` (`"ref/tool"`/`"ref/prompt"` on the wire) with the `"ref/"` prefix stripped. `ref_name` is the tool or prompt name this applies to. The handler receives the argument's name and whatever partial value the user has typed so far, and returns candidate completion strings.
A `completion/complete` request with no matching registration — an unrecognized `ref`/name, or an argument name the handler doesn't branch on — gets back an empty `values` array rather than an error; completion is a best-effort hint, not something every tool or prompt is required to support.
```json
{"completion":{"values":["us-east-1"],"hasMore":false,"total":1}}
```
:::note[Large result sets]
A handler returning more than 100 values gets truncated to the first 100, with `hasMore:true` and the untruncated `total` reported — matching the spec's guidance that servers shouldn't return huge completion lists in one response.
:::
:::note[`initialize` advertises this automatically]
`"completions":{}` appears in `initialize`'s capabilities as soon as at least one `.completion()` has been registered — there's no separate opt-in flag to call, unlike `.logging_enabled()`. A server with no completions registered simply doesn't advertise the capability, since `completion/complete` would just return empty results for everything anyway.
:::
There's no dynamic (`&self`) equivalent of `.completion()` — unlike tools/resources/prompts, completion providers are registered only via the consuming builder, before the server starts serving requests.
## Resource subscriptions
A client can subscribe to a specific resource URI and receive `notifications/resources/updated` whenever it changes — the mechanism behind live-updating resource panels in clients like Claude Desktop (watching a config file, a dashboard resource, or anything else that changes outside of a direct `tools/call`).
Call `.notify_resource_updated(uri)` from wherever your application actually changes the underlying data — a file watcher, a webhook handler, a poll loop:
```rust
use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0");
// Elsewhere, e.g. after reloading a watched config file:
server.notify_resource_updated("config://main");
```
Unlike every other notification this server pushes (`.notify()`, `.log()`, `list_changed`), which broadcast to every connected `GET /mcp` SSE client, `notify_resource_updated` is **targeted**: only sessions that called `resources/subscribe` for that exact URI receive it. This is why `resources/subscribe` and `resources/unsubscribe` both require an `Mcp-Session-Id` header (from a prior `initialize` call) — without one there's no way to later match a subscription back to a specific `GET /mcp` SSE connection.
```json
// A client subscribes:
{"method":"resources/subscribe","params":{"uri":"config://main"}}
// Later, on that same session's GET /mcp SSE stream:
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"config://main"}}
```
`resources/unsubscribe` removes the subscription; once a URI has zero subscribers, its bookkeeping entry is dropped entirely rather than left as an empty list.
:::caution[A client that disconnects without unsubscribing leaves a stale entry]
Nothing proactively notices a `GET /mcp` connection closing and removes its subscriptions — the same "no eviction without an explicit call" tradeoff already accepted for session storage elsewhere in this server. The stale session id sits harmlessly in the subscriber list for that URI (it just won't have a live SSE connection to actually deliver to) until an explicit `resources/unsubscribe` removes it.
:::
`initialize`'s `resources` capability now advertises `"subscribe":true` unconditionally, alongside the already-unconditional `listChanged:true`.
## Protocol version negotiation
The server speaks MCP 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25.
`initialize` confirms the client's `params.protocolVersion` when it is one of
those, and answers anything else — older, newer, malformed or missing — with
the newest, 2025-11-25; the client then decides whether it can continue:
```json
// A client on 2025-06-18 gets 2025-06-18:
{"method": "initialize", "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "my-client", "version": "1.0"}}}
{"result": {"protocolVersion": "2025-06-18", "capabilities": {...}, "serverInfo": {...}}}
```
The session remembers the version. After `initialize`, clients on 2025-06-18
and later send `MCP-Protocol-Version` on every request; a version the server
doesn't speak gets `400`, and a request without the header is taken as
2025-03-26 (as the transport specifies).
`params.clientInfo` (if the client sends it) is logged to stderr at `initialize` time and recorded under a freshly minted session id, returned to the client via an `Mcp-Session-Id` response header — see [Per-request context](/mcp/tools/#per-request-context) for how a `.tool_with_context()` handler gets it back on later requests in the same session.
## Sessions
`initialize` mints a session and returns its id in `Mcp-Session-Id`; the
client sends it on later requests. Sessions are bounded (since 18.0.0):
- A session idle for an hour is evicted — `.session_ttl(Duration)` changes
that. Any use keeps it alive.
- At most 10 000 sessions are kept (`.max_sessions(n)`); a new one past that
evicts the least recently used.
- `DELETE /mcp` with the session's `Mcp-Session-Id` ends it (`204`).
- A request carrying a session id the server doesn't know — never issued,
ended or evicted — gets `404`, the transport's signal to send `initialize`
again. An `initialize` carrying a stale id is accepted.
- An ended or evicted session's resource subscriptions and `GET /mcp` streams
go with it.
- At most 1000 `GET /mcp` streams are open at once (`.max_sse_clients(n)`);
another gets `503` with `Retry-After`.
```rust
use std::time::Duration;
use rust_web_server::mcp::McpServer;
let mcp = McpServer::new("my-server", "1.0")
.session_ttl(Duration::from_secs(15 * 60))
.max_sessions(1000)
.max_sse_clients(100);
```
## Built-in rws tools
The `rws` binary serves `/mcp` with 20 built-in tools when `MCP_TOKEN` is set (required as a Bearer token; without it there is no `/mcp`), not in config-driven proxy mode. See [Operate rws from an AI client](/mcp/operate/) for setup and playbooks:
| Tool name | Description |
|-----------------------|------------------------------------------|
| `server_config` | Active `RWS_CONFIG_*` values |
| `feature_flags` | List compiled feature flags |
| `server_metrics` | Live request/error/connection counters |
| `list_routes` | Registered HTTP routes |
| `cache_stats` | Response cache hit/miss and entry counts |
| `cache_clear` | Evict all response cache entries |
| `rate_limit_config` | Current rate limit settings |
| `check_rate_limit` | Check remaining quota for a client IP |
| `cors_config` | Active CORS rules |
| `list_static_files` | Files served from the static root |
| `reload_config` | Trigger a hot config reload |
| `maintenance_status` | Whether maintenance mode is on |
| `set_maintenance` | Turn maintenance mode on/off |
| `block_ip` / `unblock_ip` | Add/remove an IP on the runtime blocklist |
| `list_blocked_ips` | Currently blocked IPs |
| `feature_list` | Runtime feature flags and their state |
| `feature_set` | Enable/disable a runtime feature flag |
| `recent_requests` | Last N requests (default 20, max 100) |
| `recent_errors` | Last N 4xx/5xx requests (default 20, max 100) |
## Connecting Claude Desktop
Add the server to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-server": {
"url": "http://localhost:7878/mcp"
}
}
}
```
With bearer token authentication:
```json
{
"mcpServers": {
"my-server": {
"url": "http://localhost:7878/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}
}
```
## Connecting Cursor
In Cursor settings under **MCP Servers**, add:
```json
{
"my-server": {
"url": "http://localhost:7878/mcp"
}
}
```
Or for HTTPS deployments:
```json
{
"my-server": {
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}
```
---
# MCP Tools
<https://rws8.tech/mcp/tools/>
Tools are the primary way AI agents interact with your server. Each tool has a name, a description, a JSON Schema describing its inputs, and a handler closure that executes when the tool is called.
## Registering a tool
```rust
use rust_web_server::mcp::{McpServer, McpContent};
let mcp = McpServer::new("my-server", "1.0")
.tool(
"greet", // name (snake_case recommended)
"Return a greeting for a name", // description shown to the AI
r#"{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name to greet"
}
},
"required": ["name"]
}"#,
|args| {
let name = rust_web_server::mcp::extract_arg(args, "name")
.unwrap_or_else(|| "World".to_string());
Ok(McpContent::text(format!("Hello, {name}!")))
},
);
```
## Handler signature
```rust
Fn(&str) -> Result```
The handler receives the raw `arguments` JSON string from the MCP `tools/call` request. On success, return `Ok(McpContent)`. On failure, return `Err(String)` — this is sent back to the AI as `isError: true` (a tool-level error, not a protocol error).
## Async tool handlers
Requires the `http2` feature. A tool whose work is naturally async — an `AsyncClient` HTTP call, an async database query, awaiting another future — doesn't have to block a thread to do it inside a plain synchronous `.tool()` handler. Register it with `.async_tool()` instead:
```rust
use rust_web_server::mcp::{McpContent, McpServer};
let mcp = McpServer::new("my-server", "1.0")
.async_tool(
"call_api",
"Call an external API",
r#"{"type":"object"}"#,
|_args: &str| async move {
// let resp = AsyncClient::new().get("https://api.example.com").send().await?;
Ok(McpContent::text("response"))
},
);
```
The handler signature is `Fn(&str) -> impl Future>` — same arguments and return type as a plain `.tool()` handler, just `async`. `.register_async_tool(name, description, schema, handler)` is the dynamic (`&self`) equivalent of `.async_tool()`, usable after the server is already serving requests, the same way [`.register_tool()`](/mcp/overview/#dynamic-registration) is for sync tools. `.remove_tool(name)` removes a tool by name regardless of whether it was registered as sync or async — you don't need to remember which kind it was.
`tools/list` lists sync and async tools together, and `tools/call` dispatches to whichever collection has a matching name — from a client's point of view there is no difference between the two.
:::note[How this bridges into `Application::execute`]
`Application::execute` (and therefore `tools/call`) is a synchronous trait method. `.async_tool()`'s handler is driven to completion via `crate::async_bridge::block_on_isolated` — the same mechanism `H2ReverseProxy` and `AsyncAppWithState` already use to call async code from sync trait methods — rather than `tokio::task::block_in_place`, which only works on the `multi_thread` tokio scheduler and panics under `current_thread`. `block_on_isolated` works either way: it spawns a scoped thread with its own single-threaded runtime if already inside one, or builds a temporary runtime directly if not.
:::
:::caution[No context or annotations support yet]
Like `.tool()` (not `.tool_with_context()`), an async tool's handler only receives `arguments` — there is no `.async_tool_with_context()` or `.async_tool_annotated()` yet.
:::
## Per-request context
A plain `.tool()` handler only ever sees `arguments` — it has no way to know which client is calling, what session it's part of, or anything from the request's headers. `.tool_with_context()` registers a tool whose handler additionally receives an `McpContext`:
```rust
use rust_web_server::mcp::{McpServer, McpContent};
let mcp = McpServer::new("my-server", "1.0")
.tool_with_context(
"whoami",
"Report the caller's client info",
"{}",
|ctx, _args| {
let name = ctx.client_name.as_deref().unwrap_or("unknown client");
let version = ctx.client_version.as_deref().unwrap_or("?");
Ok(McpContent::text(format!("Called by {name} v{version}")))
},
);
```
`McpContext` fields:
| Field | Type | Source |
|---|---|---|
| `client_name` | `Option` | This session's `initialize` call's `params.clientInfo.name` |
| `client_version` | `Option` | This session's `initialize` call's `params.clientInfo.version` |
| `session_id` | `Option` | The `Mcp-Session-Id` header on this request |
| `auth_claims` | `Option` | Verified JWT claims (JSON) when [`.require_oauth()`](/mcp/auth/#reading-verified-claims) is configured; `None` otherwise |
| `progress_token` | `Option` | `params._meta.progressToken` from this `tools/call` request, if the client sent one — see [Progress reporting](#progress-reporting) |
### How the session gets established
1. A client calls `initialize`, optionally with `params.clientInfo`.
2. The server mints a session id and returns it in an `Mcp-Session-Id` response header.
3. The client echoes that header on every later request (`tools/call`, etc.).
4. `execute()` reads the header, looks up the `clientInfo` recorded for that session, and builds the `McpContext` your handler receives.
You don't need to do anything to opt into this — it's automatic for any server driven through `execute()` (i.e. any real HTTP request, whether served directly or via `TestClient`).
:::note[Calling `handle_request()` directly]
`handle_request(body)` — used in tests that skip the HTTP layer — has no `Request` to read a session header from, so `tool_with_context` handlers see an empty `McpContext` (every field `None`). Use `handle_request_with_context(body, ctx)` to supply one explicitly.
:::
:::caution[Session storage has no eviction]
Recorded sessions accumulate for the life of the process — nothing removes an entry, since the MCP Streamable HTTP transport has no session-termination signal to key cleanup off of. Fine for a modest, roughly-stable set of long-lived AI-agent clients; not recommended as-is for a public-internet-facing server serving unbounded distinct clients.
:::
## Progress reporting
If a client includes `params._meta.progressToken` on a `tools/call` request, it's asking for periodic progress updates while the tool runs. `.tool_with_context()` handlers can send them with `ctx.report_progress(progress, total, message)`, which pushes a `notifications/progress` event over the [`GET /mcp` SSE stream](/mcp/overview/#sse-streaming-transport):
```rust
use rust_web_server::mcp::{McpContent, McpServer};
let mcp = McpServer::new("my-server", "1.0")
.tool_with_context(
"process_batch",
"Process a large batch of records",
r#"{"type":"object"}"#,
|ctx, _args| {
ctx.report_progress(0.0, Some(100.0), Some("starting"));
// ... do the first half of the work ...
ctx.report_progress(50.0, Some(100.0), Some("halfway"));
// ... finish the work ...
ctx.report_progress(100.0, Some(100.0), Some("done"));
Ok(McpContent::text("batch processed"))
},
);
```
`total` and `message` are both optional (`None` omits them from the pushed event).
Where the updates go (since 18.0.0): when the call carries a `progressToken`
and the client accepts `text/event-stream` (Streamable HTTP clients do), the
`tools/call` response itself is an event stream — each progress update as
it happens, then the result, then the stream ends. Otherwise updates go to
the caller's session's `GET /mcp` stream (only that session's; before 18.0.0
every connected stream got them). Only `.tool_with_context()` handlers can report progress — a plain `.tool()` handler never receives `McpContext` at all, so it has no `progress_token` to report against.
:::note[Always safe to call]
`report_progress` silently does nothing if the client didn't ask for progress updates (`ctx.progress_token` is `None`) or if `ctx` wasn't built through a live server (e.g. `handle_request()`'s empty default context, rather than `execute()` — see the note above about calling `handle_request()` directly). A handler never needs to check whether progress reporting is actually possible before calling it.
:::
## Cancellation
A client can send `notifications/cancelled` to ask the server to abort a long-running `tools/call`. `.tool_with_context()` handlers can check `ctx.is_cancelled()` between steps of their own work and stop early:
```rust
use rust_web_server::mcp::{McpContent, McpServer};
let mcp = McpServer::new("my-server", "1.0")
.tool_with_context(
"process_batch",
"Process a large batch of records",
r#"{"type":"object"}"#,
|ctx, _args| {
for i in 0..1_000_000 {
if ctx.is_cancelled() {
return Err("cancelled by client".to_string());
}
// ... process item i ...
}
Ok(McpContent::text("done"))
},
);
```
:::caution[Cooperative, not preemptive]
Rust cannot forcibly interrupt a running synchronous closure — there is no mechanism to stop a handler mid-step unless the handler itself checks `is_cancelled()` and chooses to return early. A handler that never calls it runs to completion regardless of any `notifications/cancelled` the client sends, exactly as if this feature didn't exist. This is the same limitation `with_timeout`/`with_timeout_state` document for per-route timeouts — Rust's synchronous execution model has no forced-preemption primitive to build on.
:::
Only `.tool_with_context()` handlers can check cancellation — a plain `.tool()` handler never receives `McpContext`. `is_cancelled()` is always safe to call: it returns `false` if the client never sent a cancellation, if this wasn't a `tools/call` (the only method cancellation applies to), or if `ctx` has no live server behind it.
## Server-side sampling
:::caution[Deprecated by MCP 2026-07-28]
Sampling (`sampling/createMessage`, `ctx.sample()`) is deprecated in the [2026-07-28 MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/) (SEP-2577). It still works with clients that use it and will for at least twelve months, but new servers shouldn't rely on it; clients on the 2026-07-28 protocol have no back-channel for server-to-client requests.
:::
Most MCP traffic flows client → server. `sampling/createMessage` reverses that: the *server* asks the connected client to run LLM inference, and the client answers. This is useful for agent-to-agent or meta-agent patterns — a tool that needs its own LLM call to decide what to do next, using whatever model the client already has configured, rather than the server managing its own API key and provider.
Call `ctx.sample(request, timeout)` from a `.tool_with_context()` handler:
```rust
use rust_web_server::mcp::{McpServer, PromptMessage, SamplingRequest};
use std::time::Duration;
let mcp = McpServer::new("my-server", "1.0")
.tool_with_context(
"ask_llm",
"Ask the connected client's model a question",
r#"{"type":"object"}"#,
|ctx, _args| {
let response = ctx.sample(
SamplingRequest {
messages: vec![PromptMessage::user("What is 2+2?")],
max_tokens: 100,
system_prompt: None,
},
Duration::from_secs(30),
)?;
Ok(response.content)
},
);
```
`SamplingRequest.messages` reuses [`PromptMessage`](/mcp/prompts/#promptmessage) rather than a near-identical `SamplingMessage` type — the spec's sampling message shape (`{"role":...,"content":{"type":"text",...}}`) is exactly what `PromptMessage` already models, including its `::user()`/`::assistant()` constructors. `SamplingResponse` (what `ctx.sample()` returns on success) has `role`, `content` (an `McpContent`), `model` (which model the client actually used), and `stop_reason`.
:::caution[This blocks the calling thread]
`ctx.sample()` is not `async fn` — it blocks synchronously until the client responds or `timeout` elapses. This is deliberate: `.tool_with_context()` handlers are plain synchronous closures (`.async_tool()` handlers don't receive an `McpContext`). On a thread-pool server this ties up one worker thread for up to `timeout` — the same tradeoff per-route timeouts (`with_timeout`) already accept from the other direction.
:::
`ctx.sample()` fails fast, before sending anything, if:
- The client's `initialize` call never declared `capabilities.sampling` — sampling is a *client* capability the spec has the client declare (not something a server advertises), so a client that never said it could handle sampling requests won't get one sent to it.
- This request has no session id (`Mcp-Session-Id`) — there'd be no way to address the request to a specific client connection.
- `ctx` has no live server behind it (e.g. a context built by hand via `handle_request_with_context` in a test, rather than a real request through `execute()`).
Otherwise the request goes out on the session's [`GET /mcp` SSE stream](/mcp/overview/#sse-streaming-transport). A client that calls a tool right after `initialize` may not have opened that stream yet, so `ctx.sample()` waits for it within the same `timeout` (since 18.2.0; before, the request was dropped and the call timed out). It fails with a timeout error if the stream never opens or the client doesn't respond in time.
## Filesystem roots
:::caution[Deprecated by MCP 2026-07-28]
Roots (`roots/list`, `ctx.list_roots()`) are deprecated in the [2026-07-28 MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/) (SEP-2577). They still work with clients that use it and will for at least twelve months, but new servers shouldn't rely on them; clients on the 2026-07-28 protocol have no back-channel for server-to-client requests.
:::
A client can advertise which filesystem roots (workspace directories, mounted volumes) it has access to, so a file-system-aware tool knows to stay within them rather than assuming access to the whole filesystem. `ctx.list_roots(timeout)` asks the connected client for its roots, built on the exact same request/response mechanism as [`ctx.sample()`](#server-side-sampling) — a server-initiated `roots/list` request over SSE, answered by the client's `POST /mcp`:
```rust
use rust_web_server::mcp::{McpContent, McpServer};
use std::time::Duration;
let mcp = McpServer::new("my-server", "1.0")
.tool_with_context(
"list_workspace_files",
"List files in the client's workspace",
r#"{"type":"object"}"#,
|ctx, _args| {
let roots = ctx.list_roots(Duration::from_secs(10))?;
let names: Vec = roots.iter().map(|r| r.uri.clone()).collect();
Ok(McpContent::text(names.join(", ")))
},
);
```
Each `McpRoot` has a `uri` (typically a `file://` URI, per spec) and an optional human-readable `name`.
Unlike `ctx.sample()`, the result is **cached per session**: the first `list_roots()` call after `initialize` does a live round trip; every later call in the same session returns the cached list without sending anything, so a handler can call it on every invocation without worrying about spamming the client. The cache is invalidated when the client sends `notifications/roots/list_changed` — the next `list_roots()` call after that does a fresh round trip.
:::caution[Same blocking caveat as `ctx.sample()`]
`ctx.list_roots()` also blocks the calling thread rather than being `async fn`, for the same reason `ctx.sample()` does — see the caution above.
:::
`ctx.list_roots()` fails fast, before sending anything, under the same conditions as `ctx.sample()` (translated to roots): the client's `initialize` call never declared `capabilities.roots`, this request has no session id, or `ctx` has no live server behind it. Otherwise it fails with a timeout error if the client doesn't respond to a live request in time.
## Tool annotations
The MCP 2025-03-26 spec adds **annotations** — behavioral hints that clients like Claude Desktop use to decide whether to warn or ask for confirmation before calling a tool (e.g. skip confirmation for a read-only tool, warn before a destructive one). Register them with `.tool_annotated()`, which takes the same arguments as `.tool()` plus a `ToolAnnotations` value:
```rust
use rust_web_server::mcp::{McpServer, McpContent, ToolAnnotations};
let mcp = McpServer::new("my-server", "1.0")
.tool_annotated(
"delete_file",
"Delete a file from disk",
r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#,
ToolAnnotations {
destructive_hint: Some(true),
read_only_hint: Some(false),
idempotent_hint: Some(true), // deleting twice = deleting once
..Default::default()
},
|_args| Ok(McpContent::text("deleted")),
);
```
`ToolAnnotations` fields:
| Field | Type | Meaning |
|---|---|---|
| `read_only_hint` | `Option<bool>` | The tool does not modify its environment |
| `destructive_hint` | `Option<bool>` | The tool may perform destructive updates (only meaningful when `read_only_hint` isn't `Some(true)`) |
| `idempotent_hint` | `Option<bool>` | Calling the tool repeatedly with the same arguments has no additional effect beyond the first call |
| `open_world_hint` | `Option<bool>` | The tool may interact with an open-ended set of external entities (e.g. web search), as opposed to a fixed, closed set |
Every field defaults to `None` — build a partial set with `..Default::default()`. Only fields that are `Some` are serialized, using the spec's camelCase key names, into the tool's `tools/list` entry:
```json
{"name":"delete_file","description":"Delete a file from disk","inputSchema":{...},"annotations":{"destructiveHint":true,"readOnlyHint":false,"idempotentHint":true}}
```
A plain `.tool()` or `.tool_with_context()` tool has no `annotations` key at all.
:::caution[Hints, not enforcement]
These are advisory only — nothing in `McpServer` verifies that a handler registered with `read_only_hint: Some(true)` actually refrains from writing to disk. Set them accurately; a client may still ask for confirmation regardless.
:::
:::note[No combined context + annotations builder]
`.tool_annotated()`'s handler is `Fn(&str) -> Result` — the same plain shape as `.tool()`, not the `Fn(McpContext, &str) -> ...` shape of `.tool_with_context()`. There is currently no single builder that gives you both `McpContext` and `ToolAnnotations` on the same tool.
:::
## Argument checking
Before a handler runs, `tools/call` arguments are checked against the tool's
input schema: they must be an object, every `required` property must be
present, and each property declared in `properties` must have its declared
`type` (`string`, `number`, `integer`, `boolean`, `object`, `array`, `null`,
or a list of them). A mismatch is returned as a tool result with
`isError: true` and the reason (`missing required property "b"`), as MCP
2025-11-25 asks, so the model sees it and can retry. It isn't a full JSON
Schema validator: formats, ranges, patterns and nested schemas are still the
handler's to check.
## Structured output, titles and icons
A tool can declare the shape of its result and return it as structured data
(MCP 2025-06-18). The client gets it as `structuredContent` and, for older
clients, as JSON text:
```rust
use rust_web_server::mcp::{McpContent, McpIcon, McpServer};
let mcp = McpServer::new("calc", "1.0")
.title("Calculator") // serverInfo.title
.icon(McpIcon::new("https://example.com/calc.svg").mime_type("image/svg+xml").size("any"))
.tool("add", "Add two numbers",
r#"{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}"#,
|_args| Ok(McpContent::structured(r#"{"sum":3}"#)))
.tool_title("add", "Add numbers") // tools/list title
.tool_output_schema("add", r#"{"type":"object","properties":{"sum":{"type":"number"}},"required":["sum"]}"#)
.tool_icon("add", McpIcon::new("data:image/png;base64,…").size("48x48"));
```
`tool_title`, `tool_icon` and `tool_output_schema` apply to a tool already
registered under that name (sync or async).
## Typed tools
With the `macros` and `serde` features, a tool can take and return Rust
types (since 18.0.0): `typed_tool` derives `inputSchema` from the argument
type and `outputSchema` from the result type, deserializes the arguments,
and sends the result as `structuredContent`.
```rust
use rust_web_server::mcp::McpServer;
use rust_web_server::JsonSchema;
use serde::{Deserialize, Serialize};
/// Which item.
#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct Lookup {
/// Stock keeping unit, e.g. "MUG-01".
sku_code: String,
warehouse: Option,
}
#[derive(Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct Stock {
sku_code: String,
available: u32,
}
let mcp = McpServer::new("shop", "1.0")
.typed_tool("stock", "Units available for a SKU", |_ctx, args: Lookup| {
Ok(Stock { available: 3, sku_code: args.sku_code })
});
```
`tools/list` then shows
```json
"inputSchema": {"description":"Which item.","type":"object",
"properties":{"skuCode":{"description":"Stock keeping unit, e.g. \"MUG-01\".","type":"string"},
"warehouse":{"anyOf":[{"type":"string"},{"type":"null"}]}},
"required":["skuCode"]},
"outputSchema": {"type":"object","properties":{"skuCode":{"type":"string"},
"available":{"type":"integer","minimum":0}},"required":["skuCode","available"]}
```
- The handler gets [`McpContext`](#per-request-context) (caller, session,
OAuth claims) and the arguments; its `Err(String)` is an `isError`
result, as are arguments that don't deserialize
(`Invalid arguments: missing field \`skuCode\``).
- A result that isn't a struct (a `Vec`, a string) is sent as JSON text
without an `outputSchema` — MCP requires that schema to be an object.
- `#[derive(JsonSchema)]` covers structs with named fields and enums of unit
variants (a string `enum`). It follows `#[serde(rename, rename_all,
default, skip, skip_serializing_if)]`, so the schema matches the JSON
serde produces; `flatten`, tagged or untagged enums, `with` and the like
are compile errors rather than a schema that doesn't match. `Option`
fields aren't required; doc comments become `description`s.
- `rust_web_server::schema::JsonSchema` is the trait behind it, implemented
for primitives, `String`, `Option`, `Vec`, sets, string-keyed maps and
smart pointers; implement it by hand for anything else. A type that
contains itself ends in `{}` (any value) after 32 levels.
[Build agent tools](/mcp/agent-tools/) puts this together into an app.
## McpContent variants
```rust
use rust_web_server::mcp::McpContent;
// Plain text — sets mimeType to text/plain
McpContent::text("Operation completed successfully");
// JSON — sets mimeType to application/json
McpContent::json(r#"{"count": 42, "items": ["a", "b"]}"#);
// Image — data is base64-encoded binary, mime_type e.g. "image/png"
McpContent::image(base64_encoded_png, "image/png");
// Audio — base64 too, e.g. "audio/wav" (18.0.0)
McpContent::audio(base64_encoded_wav, "audio/wav");
// Several items in one result (18.0.0) — each renders as it would alone
McpContent::blocks([
McpContent::text("Chart for last week:"),
McpContent::image(base64_encoded_png, "image/png"),
]);
// Embedded resource — a resource included directly in the tool's response,
// as opposed to one the client fetches separately via resources/read
McpContent::embedded("docs://readme", "# My Project\n...", "text/markdown");
```
`McpContent::image(data, mime_type)` serializes to `{"type":"image","data":"<b64>","mimeType":"..."}`. This crate has no third-party dependencies, so it doesn't ship a base64 encoder — `data` must already be base64-encoded by the caller before it's passed in.
`McpContent::embedded(uri, text, mime_type)` serializes to `{"type":"resource","resource":{"uri":"...","mimeType":"...","text":"..."}}`.
`McpContent::audio` serializes like an image with `"type":"audio"`. `McpContent::blocks` is for tool results; the other variants work anywhere an `McpContent` is expected — tool results (`tools/call`) and prompt messages (`prompts/get`) alike.
## Extracting arguments
`extract_arg` pulls a string value from the arguments JSON by field name:
```rust
use rust_web_server::mcp::extract_arg;
// args = r#"{"query": "SELECT 1", "limit": 10}"#
let query = extract_arg(args, "query"); // Some("SELECT 1")
let limit = extract_arg(args, "limit"); // Some("10")
let other = extract_arg(args, "missing"); // None
```
For numeric or boolean fields, parse the returned `String`:
```rust
let limit: usize = extract_arg(args, "limit")
.and_then(|s| s.parse().ok())
.unwrap_or(100);
```
## Input schema format
The `input_schema` parameter is a JSON Schema **object** serialized as a string. Required fields:
```rust
// Minimal schema — no arguments
"{}"
// Single required string argument
r#"{"type":"object","properties":{"sql":{"type":"string"}},"required":["sql"]}"#
// Multiple arguments, one optional
r#"{
"type": "object",
"properties": {
"sql": { "type": "string", "description": "SQL query to execute" },
"limit": { "type": "integer", "description": "Max rows to return", "default": 100 }
},
"required": ["sql"]
}"#
```
## Complete example: database query tool
```rust
use rust_web_server::mcp::{McpServer, McpContent, extract_arg};
// Assume `db` is your connection pool — Arc for thread safety.
fn build_mcp(db: std::sync::Arc<crate::db::Pool>) -> McpServer {
let db_tool = db.clone();
McpServer::new("my-api", "1.0")
.tool(
"query_users",
"Query the users table by email or ID",
r#"{
"type": "object",
"properties": {
"email": { "type": "string", "description": "Filter by email address" },
"id": { "type": "integer", "description": "Filter by user ID" }
}
}"#,
move |args| {
let email = extract_arg(args, "email");
let id: Option<i64> = extract_arg(args, "id")
.and_then(|s| s.parse().ok());
let results = db_tool.query_users(email.as_deref(), id)
.map_err(|e| e.to_string())?;
// Serialize to JSON and return
let json = serde_json::to_string(&results)
.map_err(|e| e.to_string())?;
Ok(McpContent::json(json))
},
)
}
```
## Error handling
Return `Err(String)` for recoverable tool errors (invalid input, resource not found, downstream failure). The AI receives an `isError: true` response and can decide how to proceed or report it to the user.
```rust
|args| {
let id: u64 = extract_arg(args, "id")
.ok_or_else(|| "Missing required argument: id".to_string())?
.parse()
.map_err(|_| "Argument 'id' must be a positive integer".to_string())?;
fetch_record(id).map_err(|e| format!("Database error: {e}"))
.map(|r| McpContent::json(r.to_json()))
}
```
---
# MCP Resources
<https://rws8.tech/mcp/resources/>
Resources are named, URI-addressed data sources that AI agents can read. Unlike tools (which are invoked with arguments), resources are addressed by URI — the AI browses available resources via `resources/list` and fetches them with `resources/read`.
## Registering a resource
```rust
use rust_web_server::mcp::{McpServer, McpContent};
let mcp = McpServer::new("my-server", "1.0")
.resource(
"rws:///config", // URI (or URI template)
"Server Config", // human-readable name
"Current server configuration as JSON", // description
|uri| {
let config_json = r#"{"port":7878,"threads":4}"#;
Ok(McpContent::json(config_json.to_string()))
},
);
```
## URI templates
Use `{variable}` placeholders in the URI template to match any concrete URI that shares the same prefix up to the placeholder:
```rust
.resource(
"file:///{path}",
"Static File",
"Read a static file by path",
|uri| {
// uri = "file:///public/index.html"
let path = uri.strip_prefix("file:///").unwrap_or("");
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Cannot read {path}: {e}"))?;
Ok(McpContent::text(content))
},
)
```
The matching rule: a template with a `{variable}` matches any URI that starts with the text before the `{`. A template without placeholders matches only the exact URI string.
:::note[URI template parsing]
The handler receives the **full concrete URI string** — not a pre-parsed map of variable names to values. Extract variables from the URI string yourself using `strip_prefix` or similar.
:::
## Handler signature
```rust
Fn(&str) -> Result```
The handler receives the concrete URI requested by the AI (e.g., `"rws:///config"` or `"user://42"`). Return `Ok(McpContent)` with the resource content, or `Err(String)` to signal that the resource could not be read.
## McpContent for resources
```rust
use rust_web_server::mcp::McpContent;
// Plain text resource
McpContent::text("line1\nline2\nline3")
// JSON resource — mimeType set to application/json
McpContent::json(r#"{"key":"value"}"#)
// Binary resource — base64 data, sent as `blob` instead of `text` (18.0.0)
McpContent::blob(base64_encoded_png, "image/png")
```
## Resource listing
The AI discovers available resources via `resources/list` (JSON-RPC method). Each resource is listed with its URI template, name, description, and `mimeType: "text/plain"`. Register resources in the order you want them listed.
## Resource reading
The AI reads a resource via `resources/read` (JSON-RPC method) by sending the concrete URI. The server matches it against registered templates and calls the first matching handler.
## Example: expose server config as a resource
```rust
use rust_web_server::mcp::{McpServer, McpContent};
use std::env;
let mcp = McpServer::new("rws", "17")
.resource(
"rws:///config",
"Server Config",
"Live server configuration (port, threads, TLS, log format)",
|_uri| {
let port = env::var("RWS_CONFIG_PORT").unwrap_or_else(|_| "7878".to_string());
let threads = env::var("RWS_CONFIG_THREAD_COUNT").unwrap_or_else(|_| "4".to_string());
let tls = env::var("RWS_CONFIG_TLS_CERT_FILE").ok();
let log_fmt = env::var("RWS_CONFIG_LOG_FORMAT").unwrap_or_else(|_| "combined".to_string());
let json = format!(
r#"{{"port":{port},"threads":{threads},"tls":{},"log_format":"{log_fmt}"}}"#,
tls.map(|_| "true").unwrap_or("false"),
);
Ok(McpContent::json(json))
},
)
.resource(
"rws:///metrics",
"Server Metrics",
"Prometheus-format metrics snapshot",
|_uri| {
// Pull from global metrics — example only
Ok(McpContent::text("# HELP rws_requests_total Total requests\n"))
},
);
```
## Multiple resources
Chain `.resource()` calls — each registers a separate resource. Resources are matched in registration order:
```rust
McpServer::new("my-server", "1.0")
.resource("config:///app", "App Config", "Application settings", |_| Ok(McpContent::text("...")))
.resource("config:///db", "DB Config", "Database settings", |_| Ok(McpContent::text("...")))
.resource("log:///{level}", "Log Stream", "Recent log lines", |uri| {
let level = uri.strip_prefix("log:///").unwrap_or("info");
Ok(McpContent::text(format!("Log level: {level}")))
});
```
---
# MCP Prompts
<https://rws8.tech/mcp/prompts/>
Prompts are reusable message templates that clients can retrieve from the server and inject directly into a conversation. Unlike tools (which the AI calls autonomously), prompts are user-initiated: the client fetches the template, fills in arguments, and inserts the resulting messages.
## Registering a prompt
Call `.prompt(name, description, handler)` on `McpServer`:
```rust
use rust_web_server::mcp::{McpServer, PromptMessage, extract_arg};
let mcp = McpServer::new("my-server", "1.0")
.prompt(
"summarize",
"Summarize the given text",
|args| {
let text = extract_arg(args, "text")
.unwrap_or_else(|| "some text".to_string());
Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
},
);
```
The handler signature is:
```rust
Fn(&str) -> Result, String>
```
The `&str` argument is the raw `arguments` JSON object sent by the client (e.g. `{"text":"hello"}`). Return `Err(String)` to surface an error to the client.
## PromptMessage
Build individual messages with the two constructor methods:
```rust
PromptMessage::user("content") // role: "user"
PromptMessage::assistant("content") // role: "assistant"
```
A handler can return any mix of user and assistant messages. The MCP client inserts them into the conversation in order.
## Extracting arguments with `extract_arg`
`extract_arg(args_json, "param_name")` does a lightweight key lookup on the raw JSON string without pulling in a JSON library:
```rust
use rust_web_server::mcp::extract_arg;
let args = r#"{"language":"rust","code":"fn main(){}"}"#;
let lang = extract_arg(args, "language"); // Some("rust")
let missing = extract_arg(args, "other"); // None
```
Return a default or an error when the argument is absent:
```rust
let lang = extract_arg(args, "language")
.ok_or_else(|| "missing required argument: language".to_string())?;
```
## Declaring argument metadata with `prompt_with_args`
Use `.prompt_with_args` to attach typed argument definitions. These are returned in `prompts/list` so clients can render a form:
```rust
use rust_web_server::mcp::{McpServer, PromptArgDef, PromptMessage, extract_arg};
let mcp = McpServer::new("my-server", "1.0")
.prompt_with_args(
"code_review",
"Request a code review for a snippet",
vec![
PromptArgDef::required("language", "Programming language of the snippet"),
PromptArgDef::required("code", "Source code to review"),
],
|args| {
let language = extract_arg(args, "language")
.unwrap_or_else(|| "unknown".to_string());
let code = extract_arg(args, "code")
.unwrap_or_else(|| "(no code provided)".to_string());
Ok(vec![PromptMessage::user(format!(
"Please review the following {language} code and point out any bugs, \
style issues, or improvements:\n\n```{language}\n{code}\n```"
))])
},
);
```
`PromptArgDef::required(name, description)` marks the argument as required. Use `PromptArgDef::optional(name, description)` for optional ones.
## JSON-RPC methods
### `prompts/list`
The client sends:
```json
{"jsonrpc":"2.0","id":1,"method":"prompts/list"}
```
The server responds with every registered prompt including its argument definitions:
```json
{
"jsonrpc": "2.0",
"result": {
"prompts": [
{
"name": "code_review",
"description": "Request a code review for a snippet",
"arguments": [
{"name":"language","description":"Programming language of the snippet","required":true},
{"name":"code", "description":"Source code to review", "required":true}
]
}
]
},
"id": 1
}
```
### `prompts/get`
The client sends the prompt name and the filled-in arguments:
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "prompts/get",
"params": {
"name": "code_review",
"arguments": {
"language": "rust",
"code": "fn add(a: i32, b: i32) -> i32 { a + b }"
}
}
}
```
The server calls the handler with the serialised `arguments` object and responds:
```json
{
"jsonrpc": "2.0",
"result": {
"description": "Request a code review for a snippet",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review the following rust code ..."
}
}
]
},
"id": 2
}
```
## Complete example: code-review prompt
```rust
use rust_web_server::server::Server;
use rust_web_server::mcp::{McpServer, PromptArgDef, PromptMessage, extract_arg};
# #[cfg(not(feature = "http2"))]
# fn main() {
let mcp = McpServer::new("code-tools", "1.0")
.prompt_with_args(
"code_review",
"Generate a code-review request message",
vec![
PromptArgDef::required("language", "Programming language (e.g. rust, python)"),
PromptArgDef::required("code", "Source code to review"),
PromptArgDef::optional("focus", "Optional focus area, e.g. performance or safety"),
],
|args| {
let language = extract_arg(args, "language")
.ok_or_else(|| "argument 'language' is required".to_string())?;
let code = extract_arg(args, "code")
.ok_or_else(|| "argument 'code' is required".to_string())?;
let focus_clause = match extract_arg(args, "focus") {
Some(f) => format!(" Pay particular attention to {f}."),
None => String::new(),
};
Ok(vec![PromptMessage::user(format!(
"Please review the following {language} code for correctness, \
style, and potential improvements.{focus_clause}\n\n\
```{language}\n{code}\n```"
))])
},
);
let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, mcp);
# }
```
:::note[Notifications]
`notifications/initialized` is a one-way notification the client sends after the handshake. The server acknowledges it with `202 Accepted` and no body. You do not need to handle it explicitly.
:::
---
# MCP Authentication
<https://rws8.tech/mcp/auth/>
By default the MCP endpoint (`POST /mcp`) is open to any caller. Call `.require_bearer(token)` to gate every request behind a static Bearer token, or `.require_oauth(provider, audience)` (`sso` feature) for full per-user OAuth 2.0 / OIDC bearer JWT verification — see [OAuth 2.0 authorization](#oauth-20-authorization-mcp-2025-03-26) below.
## require_bearer
```rust
use rust_web_server::mcp::McpServer;
let mcp = McpServer::new("my-server", "1.0")
.require_bearer(std::env::var("MCP_TOKEN").expect("MCP_TOKEN not set"));
```
Every request to `/mcp` (`POST`, the `GET` SSE stream and `OPTIONS` alike) must include:
```
Authorization: Bearer <token>
```
A missing or incorrect token produces an immediate `401 Unauthorized` before any JSON-RPC processing:
```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: text/plain
Unauthorized
```
## Loading the token from an environment variable
Never hard-code the token in source code. The conventional variable is `MCP_TOKEN`:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
let mcp = App::new()
.mcp("my-server", "1.0")
.require_bearer(
std::env::var("MCP_TOKEN").expect("MCP_TOKEN env var not set"),
);
```
Set the variable before starting the server:
```sh
export MCP_TOKEN="$(openssl rand -hex 32)"
cargo run
```
The bundled `rws` binary mounts its built-in MCP tools only when `MCP_TOKEN`
is set to a non-empty value (since 17.113.1): its tools change the running
server, so without a token `/mcp` answers `404`. There `/mcp` also sits behind
the access log, the IP blocklist and the global rate limit (not maintenance
mode — the `maintenance` tool is how it's switched off). An empty token passed
to `require_bearer` refuses every request rather than accepting an empty
`Authorization` header, and tokens are compared in constant time.
## One token per caller
`require_bearer_tokens` (since 18.0.0) accepts several tokens and names the
caller behind each; tools see that name as `{"sub":"<name>"}` in
[`ctx.auth_claims`](#reading-verified-claims), the same place OAuth puts the
token's claims:
```rust
let mcp = McpServer::new("team-tools", "1.0")
.require_bearer_tokens([("token-for-alice", "alice"), ("token-for-ci", "ci")])
// Only alice may call tools whose names start with "admin_".
.tool_filter(|ctx, tool| {
!tool.starts_with("admin_") || ctx.auth_claims.as_deref() == Some(r#"{"sub":"alice"}"#)
});
```
`tool_filter(|ctx, tool_name| -> bool)` decides per request which tools a
caller sees in `tools/list`; calling any other answers `Unknown tool`, as
for a tool that doesn't exist. It works with `require_oauth` too (decide
from the claims). A `require_bearer` token carries no name, so its requests
have no `auth_claims`.
## Browser clients: `Origin` and `Content-Type`
A request that carries an `Origin` header — every request a browser sends —
is refused with `403` unless that origin is listed (since 17.113.1). This is
the MCP transport's required `Origin` check: without it a web page, or a
DNS-rebinding attack, could drive a local MCP server from the visitor's
browser. Clients that send no `Origin` (CLI tools, agents, Claude Desktop)
are unaffected.
```rust
let mcp = McpServer::new("my-server", "1.0")
.require_bearer(token)
.allowed_origins(["https://inspector.example.com"]);
```
The `rws` binary takes the list from `MCP_ALLOWED_ORIGINS` (comma-separated).
`POST /mcp` must carry `Content-Type: application/json` (otherwise `415`): a
form or `text/plain` body is what a page can send cross-site without a CORS
preflight.
## Configuring Claude Desktop
Add the `Authorization` header to the server entry in `claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-server": {
"url": "http://localhost:7878/mcp",
"headers": {
"Authorization": "Bearer <your-token-here>"
}
}
}
}
```
Replace `<your-token-here>` with the value of `MCP_TOKEN`.
## Full example
```rust
use rust_web_server::server::Server;
use rust_web_server::mcp::{McpServer, McpContent};
# #[cfg(not(feature = "http2"))]
# fn main() {
let token = std::env::var("MCP_TOKEN")
.expect("Set MCP_TOKEN before starting the server");
let mcp = McpServer::new("secure-server", "1.0")
.require_bearer(token)
.tool(
"ping",
"Returns pong",
r#"{"type":"object","properties":{}}"#,
|_| Ok(McpContent::text("pong")),
);
let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, mcp);
# }
```
## CORS preflight
`OPTIONS /mcp` answers `200 OK` with `Allow: GET, POST, OPTIONS` — but only after the auth check: with `.require_bearer()` or `.require_oauth()` configured, a preflight without a valid `Authorization` header gets `401`, like any other request to `/mcp`.
:::note[Multi-user auth]
`.require_bearer` enforces a single shared token — it is not a per-user authentication mechanism. For per-user access control validating JWT claims against a live IdP, use [`.require_oauth`](#oauth-20-authorization-mcp-2025-03-26) below instead of a custom wrapper. For anything else (e.g. checking a database), implement a custom `Application` wrapper that inspects the `Authorization` header and delegates to `McpServer` only on success:
```rust
use rust_web_server::application::Application;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
use rust_web_server::mcp::McpServer;
struct AuthGate {
inner: McpServer,
}
impl Application for AuthGate {
fn execute(&self, req: &Request, conn: &ConnectionInfo) -> Result {
// your custom auth logic here
self.inner.execute(req, conn)
}
}
```
:::
## OAuth 2.0 authorization (MCP 2025-03-26)
The 2025-03-26 revision of the MCP spec defines an OAuth 2.0 authorization flow for multi-tenant or enterprise deployments, where each connecting user authenticates independently rather than sharing one static token. `.require_oauth(provider, audience)` (`sso` feature) implements this by verifying the client's bearer token as a signed JWT against a live JWKS endpoint — reusing [`sso::jwks::JwksCache`](/features/sso/#rs256es256-jwt-verification-via-jwks), the same verifier `OidcAuth` and `AuthServer` use elsewhere in this crate.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["sso"] }
```
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::sso::OidcProvider;
let app = App::new()
.mcp("my-server", "1.0")
.require_oauth(OidcProvider::google(), "my-mcp-client-id");
```
`provider` supplies the issuer, JWKS URI, and (for the metadata endpoint below) the authorization/token endpoints — use a preset (`OidcProvider::google()`, `::okta(domain)`, `::keycloak(base, realm)`, etc.) or discover them live:
```rust
use rust_web_server::sso::OidcProvider;
let provider = OidcProvider::discover("https://accounts.example.com")?;
let app = mcp.require_oauth(provider, "my-mcp-client-id");
```
A request with a missing, malformed, or invalid-signature bearer token gets the same `401 Unauthorized` / `WWW-Authenticate: Bearer` response `.require_bearer()` returns — only the verification underneath differs.
### Reading verified claims
On success, the verified claims (a serialized [`OidcClaims`](/features/sso/)) populate `McpContext.auth_claims` as a JSON string, readable from any `.tool_with_context()` handler:
```rust
use rust_web_server::mcp::{McpContent, McpServer};
let mcp = McpServer::new("my-server", "1.0")
.require_oauth(rust_web_server::sso::OidcProvider::google(), "my-mcp-client-id")
.tool_with_context("whoami", "Report the caller", "{}", |ctx, _args| {
let claims = ctx.auth_claims.unwrap_or_default();
Ok(McpContent::text(claims))
});
```
### Discovery endpoint
With `.require_oauth()` configured, the server publishes RFC 9728
protected-resource metadata (MCP 2025-06-18) at
`/.well-known/oauth-protected-resource` (and at that path followed by the MCP
path, e.g. `/.well-known/oauth-protected-resource/mcp`):
```json
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://accounts.google.com"],
"bearer_methods_supported": ["header"]
}
```
A `401` points clients at it:
`WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"`.
The client then discovers the authorization server from its own metadata
and asks it for a token for `resource` (RFC 8707).
Pass this server's canonical URL as the audience —
`.require_oauth(provider, "https://mcp.example.com/mcp")`: tokens must carry
it in `aud`, and it is the metadata's `resource`. With a plain client id as
the audience, `resource` is built from the request's `Host` and the MCP
path. (Before 18.0.0 an authorization-server-style document was served at
`/.well-known/oauth-authorization-server`; it's gone.)
If `.require_oauth()` isn't configured, these paths aren't handled specially —
they fall through to the fallback app (or the built-in `App`'s `404`) like any
other non-MCP path.
### Combining with `require_bearer`
If `.require_bearer(token)` or `.require_bearer_tokens(...)` and `.require_oauth(provider, audience)` are configured on the same server, OAuth verification takes precedence and the static token is never checked. This isn't a supported combination to actually run in production — pick one — it's just an unambiguous tie-break so the behavior isn't undefined if both happen to be set.
---
# Operate rws from an AI client
<https://rws8.tech/mcp/operate/>
The `rws` binary has an MCP endpoint with 20 tools for looking at and steering
the running server: its configuration, metrics, recent requests and errors,
the response cache, the IP blocklist, maintenance mode, runtime feature flags
and config reload. An AI client connected to it can answer "why are we
throwing 500s?" from the server's own request log and act on the answer.
## Turn it on
The endpoint exists only with a token (since 17.113.1 — its tools change the
running server):
```bash
export MCP_TOKEN="$(openssl rand -hex 32)"
rws --tls-cert-file=cert.pem --tls-key-file=key.pem
```
- `/mcp` then requires `Authorization: Bearer $MCP_TOKEN`. Without
`MCP_TOKEN`, `/mcp` is `404`.
- Serve it over HTTPS (or keep the port private): the token travels in every
request.
- Browser-based clients send an `Origin` header; list theirs in
`MCP_ALLOWED_ORIGINS` (comma-separated), any other origin gets `403`.
Desktop and CLI clients send none and need nothing here.
- `/mcp` sits behind the access log, the IP blocklist and the global rate
limit, like every other path.
In config-driven proxy mode (`[[route]]` / `[[upstream]]` in
`rws.config.toml`) the built-in tools aren't mounted.
## Connect a client
**Claude Code:**
```bash
claude mcp add --transport http rws https://www.example.com/mcp \
--header "Authorization: Bearer $MCP_TOKEN"
```
**Claude Desktop** (`claude_desktop_config.json`) and **Cursor**
(`.cursor/mcp.json`):
```json
{
"mcpServers": {
"rws": {
"url": "https://www.example.com/mcp",
"headers": { "Authorization": "Bearer " }
}
}
}
```
Each tool carries behaviour hints: the 14 that only read are marked
read-only, and the ones that change something (`set_maintenance`, `block_ip`,
`unblock_ip`, `cache_clear`, `feature_set`, `reload_config`) are not, so a
client that asks before acting asks for those.
## The tools
| Look | Change |
|---|---|
| `server_config` — active `RWS_CONFIG_*` values | `reload_config` — re-read config and certificates |
| `server_metrics` — request, error, connection counters | `set_maintenance` `{"enabled": true}` — `503` for everything but health checks |
| `recent_requests` `{"n": 50}` — last requests (max 100) | `block_ip` / `unblock_ip` `{"ip": "…"}` — runtime blocklist (`403`) |
| `recent_errors` `{"n": 50}` — last 4xx/5xx | `cache_clear` — empty the response cache |
| `list_blocked_ips`, `maintenance_status` | `feature_set` `{"name": "…", "enabled": false}` |
| `cache_stats`, `rate_limit_config`, `check_rate_limit` `{"ip": "…"}` | |
| `cors_config`, `list_routes`, `list_static_files` | |
| `feature_flags`, `feature_list` | |
A call with missing or mistyped arguments comes back as an error the model
reads (`missing required property "ip"`), not a silent default.
## Playbooks
**A spike of errors.** Ask: *"Look at the last 100 errors. Is one client
responsible? If an IP is sending most of them and they look like scanning,
block it and confirm."* The client calls `recent_errors`, groups by `ip`, and
calls `block_ip`; follow-up requests from that address get `403`.
`list_blocked_ips` and `unblock_ip` undo it.
**A bad deploy.** Ask: *"Errors started a minute ago. Put the site in
maintenance mode, then show me the config."* `set_maintenance` with
`{"enabled": true}` makes every non-health path answer `503` while health
checks still pass; fix the config, ask for `reload_config`, check
`recent_errors`, and turn maintenance off again.
**Before a change.** Ask: *"What would change if I disabled the `beta-ui`
feature?"* — `feature_list`, then `feature_set` once you agree.
## What it can't do
The tools act on this process only — a second replica has its own
blocklist, maintenance switch and request log. They don't restart the
server, edit `rws.config.toml` or read files outside the static root. The
blocklist and maintenance mode are in memory: a restart clears them.
---
# Build agent tools on your app
<https://rws8.tech/mcp/agent-tools/>
An app built on rws can answer AI agents with the same functions its REST
API calls. `app.mcp(name, version)` puts an MCP endpoint at `/mcp` in front
of the app; each domain function becomes a [typed tool](/mcp/tools/#typed-tools)
whose schemas come from its Rust types; everything else still reaches the
app. The runnable version is
[`examples/mcp_inventory.rs`](https://github.com/bohdaq/rust-web-server/blob/main/examples/mcp_inventory.rs).
```bash
MCP_TOKEN=$(openssl rand -hex 32) \
cargo run --example mcp_inventory --features "macros serde sso"
curl localhost:7878/items
claude mcp add --transport http inventory http://localhost:7878/mcp \
--header "Authorization: Bearer $MCP_TOKEN"
```
## One domain, two front doors
Keep the logic in plain functions over plain types, and let the REST
handlers and the tools both call them:
```rust
/// A stock change.
#[derive(Deserialize, JsonSchema)]
pub struct AdjustStock {
pub sku: String,
/// Units to add (positive) or remove (negative).
pub delta: i64,
}
impl Inventory {
pub fn adjust(&self, change: &AdjustStock, changed_by: Option) -> Result { /* ... */ }
}
let server = App::with_state(Arc::clone(&inventory))
.post("/items/:sku/adjust", |request, params, _, inventory| { /* parse, inventory.adjust(..) */ })
.mcp("inventory", "1.0")
.typed_tool("adjust_stock", "Add or remove units of an item; fails rather than go below zero.",
move |ctx, change: AdjustStock| inventory.adjust(&change, caller(ctx.auth_claims.as_deref())))
.typed_tool("list_items", "List stocked items, optionally only those running low.",
move |_, filter: ListItems| Ok(inventory.list(&filter)))
.tool_annotations("list_items", ToolAnnotations { read_only_hint: Some(true), ..Default::default() });
```
- The doc comments on the types are what the model reads about each
argument; write them for it.
- A domain error (`only 1 of TEE-02 on hand`) returns as an `isError`
result the model can act on, not a protocol failure.
- Mark tools that only read with `read_only_hint`, so clients that ask
before acting ask only for the ones that change something.
## Who is calling
`/mcp` needs a token; the REST routes keep their own rules.
- **One shared token:** `.require_bearer(token)`.
- **Your identity provider:** `.require_oauth(OidcProvider::discover(issuer)?,
audience)`. rws checks the token's signature, issuer, expiry and audience,
serves `/.well-known/oauth-protected-resource` so clients can find the
provider, and hands the verified claims to every tool as
`ctx.auth_claims` — the example records the caller's e-mail on each stock
change. The example takes the issuer and audience from `MCP_OAUTH_ISSUER`
and `MCP_OAUTH_AUDIENCE`. See [Auth](/mcp/auth/).
## Live updates
A resource plus `notify_resource_updated` lets a client keep a view
current. The example serves `inventory://items` and, whenever the stock
changes — through a tool or through the REST API — tells every session
subscribed to it:
```rust
let notifier = server.clone();
inventory.on_change(move || notifier.notify_resource_updated("inventory://items"));
```
A client holding `GET /mcp` open (SSE) and subscribed with
`resources/subscribe` receives
`{"method":"notifications/resources/updated","params":{"uri":"inventory://items"}}`
and reads the resource again.
## Testing
`TestClient` drives the whole stack, auth included, without a socket — the
example's tests call tools over `/mcp`, then check the change through the
REST route:
```rust
let adjusted = call(&client, "adjust_stock", r#"{"sku":"TEE-02","delta":-2}"#);
assert!(adjusted.contains(r#""onHand":1"#));
assert!(client.get("/items/TEE-02").send().body_text().contains(r#""onHand":1"#));
```
`cargo test --example mcp_inventory --features "macros serde sso"` runs them.
---
# MCP gateway
<https://rws8.tech/mcp/gateway/>
`McpGateway` (since 18.0.0) federates upstream MCP servers behind one
`/mcp`. Clients connect to the gateway with their own credentials; the
gateway calls each upstream with *its* credentials, so the tokens for your
GitHub, Jira or internal MCP servers live in one place and never reach the
agents. Which caller may use which tools is one list.
The runnable version is
[`examples/mcp_gateway.rs`](https://github.com/bohdaq/rust-web-server/blob/main/examples/mcp_gateway.rs):
`cargo run --example mcp_gateway` starts two upstreams in-process and the
gateway in front of them.
```rust
use rust_web_server::mcp::gateway::{McpGateway, Upstream};
let gateway = McpGateway::new("gateway", "1.0")
.upstream(Upstream::new("github", "https://mcp.github.example/mcp")
.header("Authorization", format!("Bearer {}", std::env::var("GITHUB_MCP_TOKEN")?)))
.upstream(Upstream::new("jira", "http://jira-mcp.internal:8080/mcp"))
.allow("alice", ["*"])
.allow("ci", ["github__search_*", "jira__get_issue"])
.build()? // connects and lists every upstream's tools
.require_bearer_tokens([
(std::env::var("ALICE_TOKEN")?, "alice"),
(std::env::var("CI_TOKEN")?, "ci"),
]);
let (listener, pool) = Server::setup()?;
Server::run_tls(listener, pool, gateway).await;
```
## What callers see
- Each upstream tool appears as `<prefix>__<tool>` (`github__search_issues`),
with the upstream's description, input and output schemas, title and
behaviour hints.
- A call is checked against the upstream's input schema, then forwarded;
the upstream's result comes back as it was sent — content, structured
content, `isError`.
- When the upstream fails — it is down, answers HTTP 5xx, or returns a
JSON-RPC error — the caller gets an `isError` result naming the upstream
(`github: rate limited`), never a protocol error.
## Who may call what
`.allow(identity, tools)` rules add up:
| Pattern | Matches |
|---|---|
| `github__search_issues` | that tool |
| `github__*` | every tool from `github` (any prefix ending in `*`) |
| `*` | every tool |
The identity is the caller's `sub`: the name given with
[`require_bearer_tokens`](/mcp/auth/#one-token-per-caller), or the `sub`
claim of an OAuth token with [`require_oauth`](/mcp/auth/#oauth-20-authorization-mcp-2025-03-26).
`.allow("*", ...)` applies to every identified caller. A caller no rule
names sees an empty `tools/list`, and a tool it may not use answers as if
it didn't exist (`Unknown tool`) — nothing is forwarded. A plain
`require_bearer` token carries no identity, so behind it the gateway allows
nothing: use `require_bearer_tokens` or `require_oauth`.
## Upstreams
- `Upstream::new(prefix, url)` — `url` is the server's Streamable HTTP
endpoint. The prefix is letters, digits, `-` and single `_`.
- `.header(name, value)` — sent on every request to that upstream; the
caller's `Authorization` is never passed on.
- `.timeout_ms(ms)` — per request (default 30 s).
The gateway initializes a session with each upstream, sends the session id
and negotiated `MCP-Protocol-Version` on later requests, reads JSON or
event-stream replies, and starts a new session once if the upstream has
forgotten its own. `build()` fails, naming the upstream and its URL, if one
can't be reached at startup.
## Not yet
This is the minimal gateway. Tools are listed once, at `build()` — restart
the gateway to pick up an upstream's new tools. Upstream resources, prompts
and notifications aren't federated, results aren't cached, there are no
per-tool rate limits or audit log, and each upstream speaks the protocol
version it agreed with the gateway. The [roadmap](https://github.com/bohdaq/rust-web-server/blob/main/spec/ROADMAP.md)
tracks the full gateway (#28).
---
# Test Client
<https://rws8.tech/testing/test-client/>
`TestClient` wraps any type that implements `Application` and dispatches requests through `Application::execute` in-process. No network, no port binding, no spawned threads — tests run as fast as plain function calls.
## Creating a client
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::test_client::TestClient;
let client = TestClient::new(App::new());
```
Every request is dispatched on a synthetic `127.0.0.1:12345 → 127.0.0.1:7878` connection. Pass any `Application` implementation — `App`, `AppWithState`, `WithMiddleware`, `McpServer`, or your own type.
## State-aware applications
```rust
use std::sync::Arc;
use rust_web_server::app::App;
use rust_web_server::test_client::TestClient;
use rust_web_server::routes;
struct AppState { counter: u32 }
let state = Arc::new(AppState { counter: 42 });
let app = routes! {
App::with_state(Arc::clone(&state)),
GET "/count" => |_req, _params, _conn, s: &Arc| {
// ... build response
},
};
let client = TestClient::new(app);
```
## Building requests
Each HTTP method has a corresponding builder method on `TestClient`:
```rust
client.get("/users")
client.post("/users")
client.put("/users/1")
client.patch("/users/1")
client.delete("/users/1")
client.options("/users")
```
All return a `TestRequest` that you configure with chained builder calls before calling `.send()`.
### Adding headers
```rust
let resp = client.get("/api/data")
.header("Authorization", "Bearer my-token")
.header("Accept", "application/json")
.send();
```
### Setting the body
```rust
// Raw bytes
let resp = client.post("/upload")
.header("Content-Type", "application/octet-stream")
.body_bytes(vec![0x89, 0x50, 0x4E, 0x47])
.send();
// UTF-8 text
let resp = client.post("/echo")
.header("Content-Type", "text/plain")
.body_text("hello world")
.send();
// JSON (set Content-Type yourself)
let resp = client.post("/users")
.header("Content-Type", "application/json")
.body_text(r#"{"name":"Alice","email":"alice@example.com"}"#)
.send();
```
## Reading the response
`.send()` returns a `TestResponse`:
```rust
let resp = client.get("/healthz").send();
resp.status() // i16 — e.g. 200
resp.reason() // &str — e.g. "OK"
resp.is_success() // bool — true when 200–299
resp.body_text() // &str — panics if body is not valid UTF-8
resp.body_bytes() // &[u8] — raw body
resp.header("content-type") // Option<&str> — case-insensitive lookup
resp.headers() // &[Header] — all response headers
```
## Testing middleware
Wrap any application with middleware before handing it to `TestClient`:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::request_id::RequestIdLayer;
use rust_web_server::test_client::TestClient;
let app = App::new().wrap(RequestIdLayer::new());
let client = TestClient::new(app);
// A request without an ID gets a fresh one on the response
let resp = client.get("/healthz").send();
assert!(resp.header("x-request-id").is_some());
// An incoming ID is echoed back unchanged
let resp = client.get("/healthz").header("X-Request-Id", "abc-123").send();
assert_eq!(Some("abc-123"), resp.header("x-request-id"));
```
`RateLimitLayer` (in `middleware`) uses the process-wide `rate_limit::global()`
limiter keyed by client IP, and every `TestClient` request comes from
`127.0.0.1` — so rate-limit tests share one budget across the whole test run.
## Complete CRUD test suite
```rust
#[cfg(test)]
mod tests {
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::test_client::TestClient;
// `App::new()` has no `/users` routes: build your own app here (and
// change the return type) — the `/users` tests below assume it has them.
fn make_client() -> TestClient {
TestClient::new(App::new())
}
#[test]
fn health_check_returns_200() {
let client = make_client();
let resp = client.get("/healthz").send();
assert_eq!(200, resp.status());
}
#[test]
fn unknown_route_returns_404() {
let client = make_client();
let resp = client.get("/does-not-exist").send();
assert_eq!(404, resp.status());
}
#[test]
fn post_creates_resource() {
let client = make_client();
let resp = client
.post("/users")
.header("Content-Type", "application/json")
.body_text(r#"{"name":"Alice","email":"alice@example.com"}"#)
.send();
assert_eq!(201, resp.status());
assert!(resp.header("location").is_some());
}
#[test]
fn get_returns_json_content_type() {
let client = make_client();
let resp = client
.get("/users/1")
.header("Accept", "application/json")
.send();
assert_eq!(200, resp.status());
let ct = resp.header("content-type").unwrap_or("");
assert!(ct.contains("application/json"), "content-type was: {ct}");
}
#[test]
fn put_updates_resource() {
let client = make_client();
let resp = client
.put("/users/1")
.header("Content-Type", "application/json")
.body_text(r#"{"name":"Alice Smith"}"#)
.send();
assert_eq!(200, resp.status());
}
#[test]
fn delete_removes_resource() {
let client = make_client();
let resp = client.delete("/users/1").send();
assert!(resp.status() == 200 || resp.status() == 204);
}
#[test]
fn body_text_is_accessible() {
let client = make_client();
let resp = client.get("/healthz").send();
let body = resp.body_text();
// body is a &str; check it is non-empty or contains expected content
assert!(!body.is_empty() || resp.status() == 200);
}
}
```
## Isolated CORS and security-header tests
By default, `App::new()` reads CORS, CSP, and other settings from `RWS_CONFIG_*` environment variables on each request. That means tests which set those env vars can race against each other when `cargo test` runs in parallel — and any test that calls `override_environment_variables_from_config` must hold `test_env::lock()` for its duration.
For tests that only verify header behavior, the cleaner approach is `App::with_config`:
```rust
use rust_web_server::app::App;
use rust_web_server::server_config::ServerConfig;
use rust_web_server::test_client::TestClient;
use rust_web_server::header::Header;
#[test]
fn cors_denied_for_unlisted_origin() {
// No env writes → no test_env::lock() → runs safely in parallel.
let config = ServerConfig {
cors_allow_all: false,
cors_allow_origins: String::new(),
..ServerConfig::default()
};
let client = TestClient::new(App::with_config(config));
let resp = client
.options("/static/file.png")
.header(Header::_ORIGIN, "https://evil.example.com")
.send();
assert!(resp.header("access-control-allow-origin").is_none());
}
#[test]
fn cors_passes_for_listed_origin() {
let config = ServerConfig {
cors_allow_all: false,
cors_allow_origins: "https://trusted.example.com".to_string(),
cors_allow_credentials: "true".to_string(),
..ServerConfig::default()
};
let client = TestClient::new(App::with_config(config));
let resp = client
.options("/static/file.png")
.header(Header::_ORIGIN, "https://trusted.example.com")
.header(Header::_ACCESS_CONTROL_REQUEST_METHOD, "GET")
.send();
assert_eq!(
"https://trusted.example.com",
resp.header("access-control-allow-origin").unwrap_or("")
);
}
```
`App::with_config(config)` pins the app to a fixed `ServerConfig` for the lifetime of the `App` instance — SIGHUP and `POST /admin/config/reload` do not affect it. `App::new()` (no pinned config) continues to read env vars per request, which is correct for production and for tests that specifically verify hot-reload behavior.
`AppWithState`, `AsyncAppWithState` (requires `http2`), and `ConfigDrivenApp` (config-driven proxy) each fall through to a built-in `App` for any request their own routes/rules don't handle. That fallback reads env vars per request by default, same as `App::new()` — call `.with_config(ServerConfig)` on any of them for the same parallel-test-safe pinning:
```rust
use rust_web_server::state::AppWithState;
use rust_web_server::server_config::ServerConfig;
use rust_web_server::test_client::TestClient;
let config = ServerConfig { cors_allow_all: false, cors_allow_origins: String::new(), ..ServerConfig::default() };
let app = AppWithState::new(())
.with_config(config)
.get("/version", |_req, _params, _conn, _state| rust_web_server::response::Response::new());
let client = TestClient::new(app);
```
:::note[No TCP overhead]
Because `TestClient` bypasses the network stack entirely, tests do not need `#[tokio::test]`, `async`, or any port allocation. They run in plain `#[test]` functions and complete in microseconds.
:::
:::note[Error responses]
If `Application::execute` returns `Err(String)`, `TestClient` converts it to a synthetic `500 Internal Server Error` response whose body is the error message. This means error paths are testable with the same `resp.status()` assertions.
:::
---
# Docker
<https://rws8.tech/deployment/docker/>
## Prebuilt image
Every release publishes a multi-arch image (linux/amd64, linux/arm64) to
[Docker Hub](https://hub.docker.com/r/bohdaq/rws) as `bohdaq/rws` and to the
GitHub Container Registry as `ghcr.io/bohdaq/rws`, tagged with the full
version (`17.113.0`), the major version (`17`), and `latest`:
```bash
# Serve the current directory
docker run -p 7878:7878 -v "$PWD:/srv:ro" bohdaq/rws
# Pin a version (recommended in production)
docker run -p 7878:7878 -v "$PWD:/srv:ro" bohdaq/rws:17.113.0
```
The image is `gcr.io/distroless/static-debian12:nonroot` plus the static musl `rws` binary (about 12 MB) — no shell, no package manager, runs as a non-root user. The working directory is `/srv`: mount static content there, or put an `rws.config.toml` there to run as a config-driven proxy. Configure with `RWS_CONFIG_*` environment variables, e.g. `-e RWS_CONFIG_PORT=8080`. Each release image is started and checked (`/healthz`, the served page, `--version`) before it's published.
### Automatic HTTPS in Docker
`rws` writes the Let's Encrypt certificate, its key and the ACME account key
to `/data` (owned by the image's user). Mount a volume there so restarts reuse
the certificate instead of requesting a new one:
```bash
docker run -d --name rws -p 80:80 -p 443:443 -p 443:443/udp \
-v "$PWD:/srv:ro" -v rws-data:/data bohdaq/rws
```
with an `[acme]` section in the mounted `rws.config.toml` (see
[Automatic HTTPS](/features/acme/)). `443/udp` carries HTTP/3.
Build your own image with the Dockerfile below if you need non-default Cargo features or your own application code.
## Multi-stage Dockerfile
The recommended Dockerfile uses a two-stage build: a `rust:1.88` builder compiles the binary, then a slim Debian image carries only the final executable.
```dockerfile
# ── Stage 1: build ────────────────────────────────────────────────────────────
FROM rust:1.88 AS builder
WORKDIR /app
# Cache dependencies before copying source.
COPY Cargo.toml Cargo.lock ./
# Build a dummy main to prime the dependency cache.
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm src/main.rs
# Now copy real source and build.
COPY . .
RUN cargo build --release
# ── Stage 2: runtime ──────────────────────────────────────────────────────────
FROM debian:bookworm-slim
# Runtime dependencies for TLS (ca-certificates) and diagnostics (curl).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rws /usr/local/bin/rws
# TCP port (HTTP/1.1, HTTP/2)
EXPOSE 7878
# UDP port (HTTP/3 / QUIC) — only relevant with the http3 feature
EXPOSE 7878/udp
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s \
CMD curl -f http://localhost:7878/healthz || exit 1
CMD ["rws"]
```
:::note[Dependency cache layer]
The dummy `src/main.rs` trick builds and caches all Cargo dependencies before the real source is copied. Subsequent builds that only change application code skip the full `cargo build` of your dependencies.
:::
## Image sizes by feature flag
The default `cargo build --release` enables the `http3` feature, which includes QUIC, HTTP/2, TLS (`rustls`), and the `quinn`/`h3` stacks. Lighter builds are available:
| Feature flag | TLS | HTTP/2 | HTTP/3 | Approx. final image |
|---|---|---|---|---|
| `http1` (no-default) | No | No | No | ~3 MB |
| `http2` (no-default) | Yes (rustls) | Yes | No | ~7 MB |
| `http3` (default) | Yes (rustls) | Yes | Yes | ~12 MB |
To build with the `http1`-only feature (smallest image):
```dockerfile
RUN cargo build --release --no-default-features --features http1
```
## Exposed ports
| Port | Protocol | Purpose |
|---|---|---|
| `7878` | TCP | HTTP/1.1 and HTTP/2 (TLS upgrade via ALPN) |
| `7878/udp` | UDP | HTTP/3 / QUIC (only active when a TLS cert is configured) |
Change the port at runtime with `RWS_CONFIG_PORT`.
## Environment variable injection
All `RWS_CONFIG_*` variables can be passed via `docker run -e` or a `.env` file:
```bash
docker run \
-e RWS_CONFIG_IP=0.0.0.0 \
-e RWS_CONFIG_PORT=8080 \
-e RWS_CONFIG_THREAD_COUNT=8 \
-e RWS_CONFIG_LOG_FORMAT=json \
-p 8080:8080 \
my-rws-image
```
## docker-compose with TLS
Mount your certificate files as a read-only volume and pass the paths via environment variables:
```yaml
# docker-compose.yml
version: "3.9"
services:
rws:
build: .
ports:
- "443:7878"
- "443:7878/udp" # QUIC / HTTP/3
- "80:7879" # HTTP → HTTPS redirect port
environment:
RWS_CONFIG_IP: "0.0.0.0"
RWS_CONFIG_PORT: "7878"
RWS_CONFIG_TLS_CERT_FILE: "/run/secrets/cert.pem"
RWS_CONFIG_TLS_KEY_FILE: "/run/secrets/key.pem"
RWS_CONFIG_HTTP_REDIRECT_PORT: "7879"
RWS_CONFIG_LOG_FORMAT: "json"
volumes:
- ./certs/cert.pem:/run/secrets/cert.pem:ro
- ./certs/key.pem:/run/secrets/key.pem:ro
healthcheck:
test: ["CMD", "curl", "-fk", "https://localhost:7878/healthz"]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
restart: unless-stopped
```
:::note[SIGHUP hot-reload inside Docker]
Send `docker kill --signal=SIGHUP <container>` to reload TLS certificates, CORS rules, rate limits, and log format without restarting the container. The server's `/admin/config/reload` endpoint does the same over HTTP.
:::
---
# Kubernetes
<https://rws8.tech/deployment/kubernetes/>
## Health endpoints
`rust-web-server` exposes two dedicated health endpoints that map directly to Kubernetes probe semantics:
| Endpoint | Purpose | Probe type |
|---|---|---|
| `GET /healthz` | Always returns `200 OK` while the process is running | Liveness |
| `GET /readyz` | Returns `200 OK` when ready, `503` during startup and SIGTERM drain | Readiness |
The `SERVER_READY` flag is set to `true` after `Server::setup()` completes and cleared as soon as a shutdown signal is received, so Kubernetes stops routing traffic before in-flight requests finish draining.
## Graceful shutdown
When Kubernetes sends `SIGTERM`, the server:
1. Clears `SERVER_READY` — `/readyz` begins returning `503`, Kubernetes stops sending new traffic.
2. Stops accepting new TCP connections.
3. Waits for all in-flight requests to complete (thread-pool drain on `http1`; tokio task drain on `http2`/`http3`).
4. Exits cleanly.
Set `terminationGracePeriodSeconds` to at least `30` to give in-flight requests time to complete before Kubernetes sends `SIGKILL`.
:::tip[Helm chart]
The manifests below are also a Helm chart, published with every release (since 18.2.0):
```bash
kubectl create secret tls rws-tls --cert=tls.crt --key=tls.key
helm install my-rws oci://ghcr.io/bohdaq/charts/rws --version 18.2.0
```
It renders the same `Deployment`, `Service`, `PodDisruptionBudget` and `HorizontalPodAutoscaler`, plus an optional `Ingress` and prometheus-operator `ServiceMonitor`; CI installs it into a kind cluster with TLS on and off. Its values are documented in [`helm/rws/README.md`](https://github.com/bohdaq/rust-web-server/tree/main/helm/rws).
:::
## Deployment YAML
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: rws
labels:
app: rws
spec:
replicas: 3
selector:
matchLabels:
app: rws
template:
metadata:
labels:
app: rws
annotations:
# Prometheus scrape annotations — tells Prometheus to scrape this pod.
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "7878"
prometheus.io/scheme: "https" # the pod serves TLS
spec:
terminationGracePeriodSeconds: 30
containers:
- name: rws
image: ghcr.io/bohdaq/rws:18
ports:
- name: https
containerPort: 7878
protocol: TCP
- name: quic
containerPort: 7878
protocol: UDP
env:
- name: RWS_CONFIG_IP
value: "0.0.0.0"
- name: RWS_CONFIG_PORT
value: "7878"
- name: RWS_CONFIG_THREAD_COUNT
value: "4"
- name: RWS_CONFIG_LOG_FORMAT
value: "json"
- name: RWS_CONFIG_TLS_CERT_FILE
value: "/tls/tls.crt"
- name: RWS_CONFIG_TLS_KEY_FILE
value: "/tls/tls.key"
volumeMounts:
- name: tls
mountPath: /tls
readOnly: true
livenessProbe:
httpGet:
path: /healthz
port: 7878
scheme: HTTPS # the port serves TLS; plain-HTTP probes would fail
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 7878
scheme: HTTPS # the port serves TLS; plain-HTTP probes would fail
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "500m"
memory: "256Mi"
volumes:
- name: tls
secret:
secretName: rws-tls
```
Every `RWS_CONFIG_*` variable can come from a ConfigMap or Secret. Settings that name a file, such as `RWS_CONFIG_TLS_CERT_FILE`, take a path, so mount the Secret as a volume (as above) rather than putting its contents in the variable:
```yaml
envFrom:
- configMapRef:
name: rws-config # e.g. RWS_CONFIG_LOG_FORMAT, RWS_CONFIG_THREAD_COUNT
```
## Service YAML
```yaml
apiVersion: v1
kind: Service
metadata:
name: rws
spec:
selector:
app: rws
ports:
- name: https
protocol: TCP
port: 443
targetPort: 7878
- name: quic
protocol: UDP
port: 443
targetPort: 7878
type: ClusterIP
```
## PodDisruptionBudget
Keep at least two pods running during rolling updates or node drains:
```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: rws-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: rws
```
## HorizontalPodAutoscaler
Scale on CPU utilisation, or on requests per second per pod. rws exports the
counter `rws_requests_total` at `/metrics`; the
[Prometheus Adapter](https://github.com/kubernetes-sigs/prometheus-adapter)
turns it into the per-pod rate `rws_requests_per_second` in the custom
metrics API. Install it with the rule in
[`helm/prometheus-adapter-values.yaml`](https://github.com/bohdaq/rust-web-server/blob/main/helm/prometheus-adapter-values.yaml):
```bash
helm install prometheus-adapter prometheus-community/prometheus-adapter \
-n monitoring -f helm/prometheus-adapter-values.yaml # set prometheus.url first
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/rws_requests_per_second"
```
The rule:
```yaml
rules:
custom:
- seriesQuery: 'rws_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: namespace}
pod: {resource: pod}
name:
matches: "^rws_requests_total$"
as: "rws_requests_per_second"
metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'
```
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: rws-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: rws
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
# Requests per second per pod, from the Prometheus Adapter rule above.
- type: Pods
pods:
metric:
name: rws_requests_per_second
target:
type: AverageValue
averageValue: "500"
```
With the Helm chart: `--set autoscaling.enabled=true --set autoscaling.customMetric.enabled=true`.
(Until 18.2.0 this page used an `External` metric named `rws_route_requests_total`, which nothing served.)
## Structured logging for Kubernetes
Access logs are JSON by default (`RWS_CONFIG_LOG_FORMAT=json`, or `log_format = 'json'` in `rws.config.toml`; `combined` gives Combined Log Format, `off`/`none` disables the access log). Kubernetes log aggregators (Fluentd, Fluent Bit, Vector) can then parse and index each field:
```json
{"time":"2026-07-02T12:00:00Z","remote_addr":"10.0.0.1","method":"GET","path":"/api/users","protocol":"HTTP/1.1","status":200,"bytes":1024}
```
:::note[Log to stdout]
`rust-web-server` writes all access logs to stdout (buffered per thread, written at least every 100 ms). Let the container runtime and your log aggregator handle collection and rotation — this is the Kubernetes-native approach.
:::
## Prometheus scraping
With the `prometheus.io/*` annotations on the pod (`prometheus.io/scheme: "https"` when the pod serves TLS), a Prometheus using annotation-based discovery scrapes `GET /metrics` automatically; with the prometheus-operator, enable the chart's `metrics.serviceMonitor` instead. The endpoint returns server-wide counters and — if `MetricsLayer` is enabled — per-route histograms in Prometheus text format. Circuit-breaker state (`rws_circuit_breaker_state{backend}`) is included automatically. The full metric list is on the [Metrics](/features/metrics/) page.
See the [Observability](/deployment/observability) page for example Grafana queries.
---
# Kubernetes Ingress
<https://rws8.tech/deployment/kubernetes-ingress/>
`KubernetesIngressWatcher` watches the Kubernetes API server for Ingress resources and maintains a live route table in memory. `IngressRouter` implements `Application` and uses that table to forward requests to the correct upstream service.
## How it works
1. `KubernetesIngressWatcher` does a full `GET /apis/networking.k8s.io/v1/namespaces/{ns}/ingresses` on an interval (default 30 s, configurable) as a resync safety net, **and** opens a long-lived `?watch=true` streaming connection alongside it — any `ADDED`/`MODIFIED`/`DELETED` event on that stream triggers an immediate re-list instead of waiting for the next interval. On a quiet cluster the watch connection just sits blocked on a read, costing nothing; if the API server doesn't support watch for some reason, the interval resync alone keeps things working, just at up-to-30s latency instead of near-instant.
2. Each Ingress rule is parsed into an `IngressRule` containing `host`, `path`, `path_type`, `service_name`, `service_port`, and `namespace`.
3. `IngressRouter` matches incoming requests (host header + path, per `path_type` — see [Path matching](#path-matching)) against the live rule table and proxies to `{service_name}.{namespace}.svc.cluster.local:{service_port}` over HTTP/1.1.
## Environment variables
| Variable | Required | Example | Description |
|---|---|---|---|
| `RWS_K8S_API_SERVER` | Yes (for `from_env()`) | `http://localhost:8001` | Base URL of the Kubernetes API (plain HTTP) |
| `RWS_K8S_TOKEN` | No | `eyJhbGci…` | Bearer token for API authentication |
| `RWS_K8S_NAMESPACE` | No | `production` | Namespace to watch (default: `default`; use `all` for all namespaces) |
:::note[Two ways to reach the API server]
`from_env()` talks plain `http://` to the Kubernetes API — the simplest option, and the only one that needs no extra Cargo feature. The recommended setup for local development or a sidecar is `kubectl proxy`:
```sh
kubectl proxy &
export RWS_K8S_API_SERVER=http://localhost:8001
export RWS_K8S_TOKEN=
export RWS_K8S_NAMESPACE=default
```
For a pod that wants to talk to the in-cluster API server directly — no sidecar — use [`from_service_account()`](#connecting-directly-from-inside-a-pod) instead, which connects over TLS to `https://kubernetes.default.svc` and requires the `http-client` or `http2` feature.
:::
## Quick start
```rust
use rust_web_server::ingress::{IngressRouter, KubernetesIngressWatcher};
use rust_web_server::server::Server;
fn main() {
let watcher = KubernetesIngressWatcher::from_env()
.expect("Set RWS_K8S_API_SERVER before starting");
watcher.start(); // spawns background polling thread
let app = IngressRouter::new(watcher);
let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, app);
}
```
## Configuration options
```rust
let watcher = KubernetesIngressWatcher::from_env()
.unwrap()
.namespace("production") // override namespace
.ingress_class("rws") // only watch Ingress objects with spec.ingressClassName: rws
.poll_interval_secs(15); // resync every 15 s instead of 30 (watch still runs alongside)
let app = IngressRouter::new(watcher)
.connect_timeout_ms(2_000) // TCP connect to upstream (default 5 000 ms)
.read_timeout_ms(60_000); // response read timeout (default 30 000 ms)
```
### IngressClass filtering
On a single-controller cluster, `.ingress_class(...)` doesn't need to be set — the watcher picks up every Ingress object regardless of `spec.ingressClassName` by default, matching the pre-filtering behavior this crate has always had. On a **multi-controller** cluster (e.g. `rws` running alongside `nginx-ingress`), set `.ingress_class("rws")` so this watcher only builds routes from Ingress objects meant for it — an Ingress with no `ingressClassName` at all never matches a configured filter, so it needs one explicitly to be picked up.
## Path matching
`IngressRule::matches` implements the two Kubernetes `pathType` values that carry real routing semantics:
- If `host` is non-empty, the incoming `Host` header must match (case-insensitive).
- Any query string on the request URI is ignored for path matching.
- **`Prefix`** (the default when `pathType` is absent, and how `ImplementationSpecific` is also treated) matches on whole path *segments*, not raw bytes: rule path `/foo` matches `/foo`, `/foo/`, and `/foo/bar`, but **not** `/foobar`.
- **`Exact`** requires the request path to equal the rule's path exactly (ignoring the query string).
The first matching rule wins. Rules are evaluated in the order they are returned by the API server.
Upstream addresses are resolved as:
```
{service_name}.{namespace}.svc.cluster.local:{service_port}
```
## RBAC requirements
The pod's service account needs read access to Ingress resources. Apply the following manifests:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: rws-ingress
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rws-ingress-reader
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: rws-ingress-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: rws-ingress-reader
subjects:
- kind: ServiceAccount
name: rws-ingress
namespace: default
```
## Deployment example
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: rws-ingress-controller
spec:
replicas: 1
selector:
matchLabels:
app: rws-ingress-controller
template:
metadata:
labels:
app: rws-ingress-controller
spec:
serviceAccountName: rws-ingress
containers:
- name: rws
image: your-registry/rust-web-server:latest
ports:
- containerPort: 7878
env:
- name: RWS_K8S_API_SERVER
value: "http://localhost:8001"
- name: RWS_K8S_TOKEN
value: ""
- name: RWS_K8S_NAMESPACE
value: "default"
# kubectl proxy sidecar exposes the API over plain HTTP
- name: kubectl-proxy
image: bitnami/kubectl:latest
args: ["proxy", "--port=8001", "--address=127.0.0.1"]
```
## Connecting directly from inside a pod
`from_service_account()` (requires the `http-client` or `http2` feature — both already pull in `rustls`) reads the token, namespace, and CA certificate from the files Kubernetes mounts into every pod at `/var/run/secrets/kubernetes.io/serviceaccount/`, and finds the API server via the `KUBERNETES_SERVICE_HOST`/`KUBERNETES_SERVICE_PORT` environment variables every pod also has injected automatically — the same mechanism every other Kubernetes client library uses. No `kubectl proxy` sidecar needed:
```rust
use rust_web_server::ingress::{IngressRouter, KubernetesIngressWatcher};
let watcher = KubernetesIngressWatcher::from_service_account()
.expect("not running inside a pod, or the http-client/http2 feature isn't enabled");
watcher.start();
let app = IngressRouter::new(watcher);
```
The connection is TLS, trusting **only** the cluster's own CA certificate (`.../ca.crt`) — not the public root store `http_client` uses elsewhere in this crate, since the API server's certificate is signed by that private, cluster-specific CA. Building without the `http-client`/`http2` feature leaves `from_service_account()` returning a clear error pointing back at the `kubectl proxy` + `from_env()` path above, rather than silently failing at connect time.
```toml
[dependencies]
rust-web-server = { version = "18", features = ["http-client"] }
```
---
# Observability
<https://rws8.tech/deployment/observability/>
## Metrics
### Built-in Prometheus endpoint
`GET /metrics` is always available and returns metrics in the Prometheus text exposition format. No configuration is required.
Server-wide counters updated by the request loop:
| Metric | Type | Description |
|---|---|---|
| `rws_requests_total` | Counter | Total HTTP requests received |
| `rws_errors_total` | Counter | Connections whose handling ended in an error (I/O or application error, or a panicked handler) — not a count of 5xx responses |
| `rws_active_connections` | Gauge | Number of currently open connections |
### Per-route metrics with MetricsLayer
Add `MetricsLayer` to any application to record per-route counters and latency histograms:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::metrics::MetricsLayer;
let app = App::new().wrap(MetricsLayer);
```
`MetricsLayer` adds:
| Metric | Type | Labels | Description |
|---|---|---|---|
| `rws_route_requests_total` | Counter | `method`, `path`, `status` | Requests per route and status code |
| `rws_route_duration_seconds` | Histogram | `method`, `path` | Response time distribution |
### Circuit breaker state
Present automatically — no `MetricsLayer` or other opt-in needed — for every backend known to [`circuit_breaker::global()`](/proxy/circuit-breaker/):
| Metric | Type | Labels | Description |
|---|---|---|---|
| `rws_circuit_breaker_state` | Gauge | `backend` | `0`=closed, `1`=half_open, `2`=open |
Wire `ReverseProxy::with_circuit_breaker(Arc::new(circuit_breaker::global()))` to get this metric for free alongside [automatic breaker integration](/proxy/circuit-breaker/#automatic-reverseproxy-wiring).
### Grafana / PromQL queries
```promql
# Request rate (requests per second over 1 m)
rate(rws_requests_total[1m])
# Error rate
rate(rws_errors_total[1m])
# p99 latency per route
histogram_quantile(0.99, rate(rws_route_duration_seconds_bucket[5m]))
# Top routes by request count
topk(10, sum by (path) (rate(rws_route_requests_total[5m])))
# Backends currently tripped open
rws_circuit_breaker_state == 2
```
## Logs
### JSON structured logs (default)
By default the server writes one newline-delimited JSON object per request to stdout:
```json
{"time":"2026-07-02T12:00:00Z","remote_addr":"127.0.0.1","method":"GET","path":"/healthz","protocol":"HTTP/1.1","status":200,"bytes":2}
```
JSON logs are well-suited for ingestion by Loki, Fluentd, Datadog, and other structured-log collectors.
### Combined Log Format
Set `RWS_CONFIG_LOG_FORMAT=combined` (via environment variable or `rws.config.toml`) for Apache Combined Log Format (CLF):
```
127.0.0.1 - - [02/Jul/2026:12:00:00 +0000] "GET /healthz HTTP/1.1" 200 2
```
This format is compatible with GoAccess, AWStats, and most log-analysis tools.
To produce the same line yourself — in your own middleware or a custom server loop — call `Log::combined`:
```rust
use rust_web_server::log::Log;
// request: &Request, response: &Response, peer_addr: &SocketAddr
let line = Log::combined(&request, &response, &peer_addr);
println!("{}", line);
// 192.168.1.1 - - [29/Jun/2026:14:23:05 +0000] "GET /index.html HTTP/1.1" 200 1234
```
`RWS_CONFIG_LOG_FORMAT=off` writes no access log at all.
### Buffered writes
Access-log lines are buffered per worker thread and written to stdout by a
background thread every 100 ms — at once when a thread's buffer passes
32 KiB, when a thread exits, and when the process exits normally (including
a graceful shutdown on `SIGTERM`/`SIGINT`). Writing each line with its own
`write` under the process-wide stdout lock cost about a sixth of a small
request's CPU. A line can therefore appear up to 100 ms after its request;
lines from one thread keep their order, and every line carries its own
timestamp. Code that ends the process some other way can call
`Log::flush_access_log()` first.
### Writing to a file
Set `access_log` (`RWS_CONFIG_ACCESS_LOG`, since 18.1.0) to write the access
log to a file instead of stdout:
```toml
log_format = "combined"
access_log = "/var/log/rws/access.log"
access_log_max_size = "100M" # optional: rotate past 100 MiB
access_log_keep = 5 # access.log.1 … access.log.5
```
- Lines are appended; the file and its directory must be writable by rws.
- **Rotation by rws:** with `access_log_max_size`, the file is renamed
`access.log.1` (older ones shift up, the oldest past `access_log_keep` is
deleted) and a new one is started when the next lines would pass the
size.
- **Rotation by logrotate:** leave `access_log_max_size` at `0` and have
logrotate send `SIGUSR1` after moving the file; rws reopens the path
before its next write:
```
/var/log/rws/access.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
postrotate
systemctl kill -s USR1 rws.service
endscript
}
```
Without an access-log file `SIGUSR1` keeps its default action (it ends
the process), so don't send it to an rws logging to stdout.
### Log aggregation in Kubernetes
The server writes all logs to stdout. Kubernetes log aggregation pipelines pick them up automatically:
```sh
# View logs in real time
kubectl logs -f deployment/my-rws-app
# Ship to Loki via Promtail — no application config required; just label the pod
```
## Tracing
### OtelLayer
`OtelLayer` implements `Middleware` and propagates W3C `traceparent` headers. Add it to your application and configure an OTLP exporter:
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::otel::{self, ExporterConfig, OtelLayer, TracingConfig};
otel::setup(TracingConfig {
service_name: "my-service".to_string(),
exporter: ExporterConfig::Otlp {
endpoint: "http://tempo:4318".to_string(),
},
..TracingConfig::default()
});
let app = App::new().wrap(OtelLayer);
```
The layer reads the incoming `traceparent` header, creates a span for each request (a child of the caller's span when one was sent), and exports completed spans to the configured OTLP endpoint. Start nested spans inside a request with `otel::span(name)` / `otel::client_span(name)` — see [Distributed Tracing](/features/tracing/#child-spans).
### Supported backends
- **Grafana Tempo** — point `endpoint` at the OTLP HTTP port (default `4318`)
- **Jaeger** — enable the OTLP receiver and point at port `4318`
- **OpenTelemetry Collector** — use as a fan-out proxy to multiple backends
### Distributed tracing across services
Include the `traceparent` header in outbound requests using the synchronous HTTP client:
```rust
use rust_web_server::http_client::Client;
let response = Client::new()
.get("http://downstream-service/api/data")
.header("traceparent", incoming_traceparent)
.send()?;
```
:::caution[Coming Soon]
Baggage propagation, and spans created automatically for database queries and outbound calls.
:::
## Health endpoints
Two health endpoints are built into every server instance without any configuration:
### GET /healthz — liveness
Returns `200 OK` as long as the binary is running. Use this as the Kubernetes liveness probe:
```yaml
livenessProbe:
httpGet:
path: /healthz
port: 7878
initialDelaySeconds: 5
periodSeconds: 10
```
### GET /readyz — readiness
Returns `200 OK` during normal operation. Returns `503 Service Unavailable` during graceful shutdown (after `SIGTERM` is received, while in-flight requests are being drained). Use as the Kubernetes readiness probe:
```yaml
readinessProbe:
httpGet:
path: /readyz
port: 7878
initialDelaySeconds: 3
periodSeconds: 5
```
The readiness gate ensures the load balancer stops routing new traffic to a pod as soon as it begins draining, giving in-flight requests time to complete before the pod terminates.
---
# systemd
<https://rws8.tech/deployment/systemd/>
On a Linux host without containers, run `rws` as a systemd service. The unit
below runs it as an unprivileged user that can bind ports 80 and 443, sees
the system read-only except for its own state and log directories, and
reloads its configuration on `systemctl reload`.
## Install from a package
Since 18.2.0 rws has `.deb` and `.rpm` packages for x86_64 and arm64, from
signed repositories (so `apt upgrade` / `dnf upgrade` bring new releases) or
from each [GitHub release page](https://github.com/bohdaq/rust-web-server/releases).
They install the binary as `/usr/bin/rws`, the unit below, a configuration
in `/etc/rws/rws.config.toml` and a placeholder page in `/var/www/rws`,
create the `rws` user, and start the service on port 7878.
Debian, Ubuntu:
```bash
curl -fsSL https://rws8.tech/apt/rws.gpg | sudo tee /usr/share/keyrings/rws.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/rws.gpg] https://rws8.tech/apt stable main" \
| sudo tee /etc/apt/sources.list.d/rws.list
sudo apt update && sudo apt install rws
curl localhost:7878/ # rws is running
```
Fedora, RHEL, Rocky, Alma (packages and repository metadata are signed):
```bash
sudo curl -fsSL -o /etc/yum.repos.d/rws.repo https://rws8.tech/rpm/rws.repo
sudo dnf install rws
```
A downloaded package installs the same way: `sudo apt install ./rws_18.2.0_amd64.deb`,
`sudo dnf install ./rws-18.2.0-1.x86_64.rpm`. The repositories hold the
newest release only; older ones stay on the release pages.
Put your site in `/var/www/rws`, change settings in
`/etc/rws/rws.config.toml` (an upgrade keeps your edits) and apply them with
`sudo systemctl reload rws`. To serve on port 80, set `port = 80` — the unit
already grants the capability.
## Install by hand
```bash
sudo install -m 0755 rws /usr/bin/rws
sudo useradd --system --user-group --no-create-home --home-dir /var/lib/rws --shell /usr/sbin/nologin rws
sudo mkdir -p /etc/rws /var/www/rws
sudo cp rws.config.toml /etc/rws/
sudoedit /etc/systemd/system/rws.service # the unit below
sudo systemctl daemon-reload
sudo systemctl enable --now rws
```
## The unit
The packages install this file as `/usr/lib/systemd/system/rws.service`
([`packaging/linux/rws.service`](https://github.com/bohdaq/rust-web-server/blob/main/packaging/linux/rws.service)):
```ini
[Unit]
Description=rws web server
Documentation=https://rws8.tech
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=rws
Group=rws
WorkingDirectory=/var/www/rws
Environment=RWS_CONFIG_FILE=/etc/rws/rws.config.toml
# ACME certificates and account key in /var/lib/rws (StateDirectory below).
Environment=RWS_CONFIG_ACME_CERT_PATH=/var/lib/rws/cert.pem
Environment=RWS_CONFIG_ACME_KEY_PATH=/var/lib/rws/key.pem
Environment=RWS_CONFIG_ACME_ACCOUNT_KEY_PATH=/var/lib/rws/acme_account.key
ExecStart=/usr/bin/rws
# Re-read the config and certificates without dropping connections.
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
LimitNOFILE=65536
# Ports 80/443 without root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
# Writable: /var/lib/rws (state) and /var/log/rws (access log) only.
StateDirectory=rws
LogsDirectory=rws
ProtectSystem=strict
ReadOnlyPaths=/var/www/rws /etc/rws
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0027
[Install]
WantedBy=multi-user.target
```
`systemd-analyze security rws.service` rates it "OK" or better; check it on
your host after installing.
## Access log
Write the access log under `/var/log/rws` (the unit's `LogsDirectory`) in
`/etc/rws/rws.config.toml`:
```toml
access_log = "/var/log/rws/access.log"
```
Rotate it with logrotate, which tells rws to reopen the file with
`systemctl kill -s USR1 rws.service` — see
[Writing to a file](/deployment/observability/#writing-to-a-file) for the
logrotate snippet, or set `access_log_max_size` and let rws rotate it.
Without `access_log`, lines go to stdout and land in the journal
(`journalctl -u rws`).
## Day to day
| Task | Command |
|---|---|
| Check a config change before applying it | `rws --check /etc/rws/rws.config.toml` |
| Apply it (config, routes, certificates) | `sudo systemctl reload rws` |
| Logs and errors | `journalctl -u rws -f` |
| Reopen the access log | `sudo systemctl kill -s USR1 rws` |
| Restart | `sudo systemctl restart rws` |
---
# Windows
<https://rws8.tech/deployment/windows/>
The `rws.exe` in each release's `x86_64-pc-windows-msvc` zip on the
[GitHub release page](https://github.com/bohdaq/rust-web-server/releases)
runs as it does elsewhere: it serves the current directory on port 7878.
```powershell
Expand-Archive rws-18.2.0-x86_64-pc-windows-msvc.zip C:\rws
cd C:\www
C:\rws\rws-18.2.0-x86_64-pc-windows-msvc\rws.exe
```
## As a Windows service
`rws.exe --windows-service=<directory>` runs under the Service Control
Manager (since 18.2.0): it serves `<directory>` and reads
`<directory>\rws.config.toml` if there is one. Stopping the service — or
shutting Windows down — stops rws gracefully, finishing requests in flight.
In an administrator PowerShell:
```powershell
New-Service -Name rws -DisplayName "rws web server" -StartupType Automatic `
-BinaryPathName '"C:\rws\rws.exe" --windows-service="C:\www"'
Start-Service rws
curl.exe http://localhost:7878/healthz
```
| Task | Command |
|---|---|
| Stop / start | `Stop-Service rws`, `Start-Service rws` |
| Apply a config change | `Restart-Service rws` — there is no SIGHUP on Windows. With `watch = true` rws reloads settings and routes by itself ([Hot reload](/features/hot-reload/)); a new certificate still needs a restart |
| Remove | `Stop-Service rws; sc.exe delete rws` |
The directory is required: a service starts in `C:\Windows\System32`, and
rws serves its working directory. Keep only what should be public in it
(rws never serves `rws.config.toml`, `*.pem` or `*.key`).
A service has no console, so its access log goes nowhere unless you write it
to a file — in `<directory>\rws.config.toml`:
```toml
access_log = 'C:\rws\logs\access.log'
access_log_max_size = 104857600 # rotate at 100 MiB
```
The service runs as LocalSystem by default; to run it as a less privileged
account, set one with `sc.exe config rws obj= "NT AUTHORITY\LocalService"`
and give that account read access to the directory and write access to the
log directory.
---
# WASM Guest Component
<https://rws8.tech/deployment/wasm/>
`rws-wasm-shim` is a separate workspace package that runs the same `App`/`Router`/middleware logic you use natively — but hosted inside a WASM component instead of a `TcpListener`. The host (Wasmtime, Spin, Fastly Compute) owns the listening socket and TLS termination; it calls the guest once per request through the standard [`wasi:http/proxy`](https://github.com/WebAssembly/wasi-http) world.
This is the "Foundation + Phase 1 + most of Phase 2" slice of the project tracked in [`spec/WASM_SHIM.md`](https://github.com/bohdaq/rust-web-server/blob/main/spec/WASM_SHIM.md) in the repository — read that file for the full multi-phase plan and every verification detail. Everything on this page was tested against a real `wasmtime serve` process, not just compiled.
## Why not just compile the whole server to WASM?
`rws`'s normal accept loop uses `std::thread` (`ThreadPool`) and `rustls`/`aws-lc-rs` for TLS — neither compiles for `wasm32-wasip2`. Rather than trying to port sockets and TLS into the sandbox, the guest component reuses the existing seam every controller and middleware layer already goes through:
```rust
pub trait Application {
fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result;
}
```
`Request` and `Response` are plain data structs with no socket handle anywhere in them, so the same `App::execute()` call that runs natively behind a `TcpListener` also runs unchanged behind a `wasi:http` incoming request.
## Building and running
```bash
rustup target add wasm32-wasip2 # wasm32-wasip2 is Tier 2 since Rust 1.82
cd rws-wasm-shim
cargo build --release --target wasm32-wasip2
wasmtime serve target/wasm32-wasip2/release/rws_wasm_shim.wasm
```
In another shell:
```bash
curl http://127.0.0.1:8080/healthz
curl http://127.0.0.1:8080/
```
The build output (`rws_wasm_shim.wasm`) is already a valid WASM component on this target — no separate `wasm-tools component new` step is needed.
The adapter is a small `Guest::handle` implementation (full version in `rws-wasm-shim/src/lib.rs`):
```rust
use wasip2::exports::http::incoming_handler::Guest;
use wasip2::http::types::{IncomingRequest, ResponseOutparam};
use rust_web_server::app::App;
use rust_web_server::application::Application;
use rust_web_server::core::New;
wasip2::http::proxy::export!(Shim);
struct Shim;
impl Guest for Shim {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
// translate `request` into a rust_web_server::request::Request,
// call App::new().execute(&request, &connection), and write the
// Response back through `response_out`.
}
}
```
## What runs inside the guest
Only the parts of `rust-web-server` that don't touch a socket or spawn a thread are compiled for `wasm32-wasip2` — everything else is behind `#[cfg(not(target_arch = "wasm32"))]`. This list grew substantially past the initial "core only" slice — outbound HTTP now works, which unblocks everything built on top of it:
| Available in the guest | Native-only (excluded for `wasm32`) |
|---|---|
| `App`, `Router`, `Controller`, extractors | `Server`, `ThreadPool` |
| In-memory middleware (CORS, CSRF, rewrite, IP filter, request ID, in-memory rate limit/cache) | `ReverseProxy`, `H2ReverseProxy`, `GrpcProxy`, `TcpProxy`, `UdpProxy`, `WsProxy` |
| `Response::json`/`::text`, static-file serving (streamed for large files too) | `WebSocket`, `mailer` (raw SMTP — no `wasi:http` equivalent for arbitrary TCP protocols) |
| `http_client::Client` — **both HTTP and HTTPS**, via a `wasi:http` `outgoing-handler` backend (TLS terminated by the host, not `rustls`) | `otel`, `log`, `Scheduler` (background jobs), `redis_protocol`, `circuit_breaker`, `canary`, `service_discovery`'s DNS/etcd/Consul/Docker sources, `ingress`, `timeout`, `proxy_config` (config-driven proxy) |
| `auth` (`BasicAuthLayer`, `JwtLayer`, `ForwardAuthLayer`), `auth-asymmetric`, `csrf`, `crypto`, `webhook`, `rewrite-regex` | `model` (async ORM — needs real DB wire protocols) |
| `sso`, `sso-server`, `sso-saml`, `secrets`, `storage-s3`, `storage-azure` — all work because they only ever used `http_client::Client`, no code changes needed | |
## Three real limitations (confirmed, not theoretical)
All of these were empirically verified against a real `wasmtime serve` process, not inferred from the spec:
1. **Stateful middleware does not survive across requests.** `wasmtime serve` instantiates a **fresh guest per request** — confirmed by making an adapter call a request counter five times per invocation and watching it read back exactly `5` on every single request, never `10` or `15`. `RateLimiter`, `CacheLayer`, in-memory sessions, and metrics counters all silently reset every request under this host. Other hosts (Fastly Compute, Spin) are widely understood to behave the same way for the same isolation reasons, though only `wasmtime serve` was directly tested here. There is no in-crate fix for this — it needs either a host-provided KV binding (the `wasi-keyvalue` proposal, still pre-1.0) or accepting that stateful middleware requires a specific instance-reuse-guaranteeing host.
2. **Filesystem access needs an explicit host grant.** Unlike outbound HTTP (part of the `wasi:http/proxy` world's base contract), filesystem access is opt-in — `wasmtime serve` requires `--dir HOST_DIR::.` to expose any directory to the guest. Without it, static-file controllers silently fall back to `rws`'s built-in embedded default pages for every path, with no error — easy to miss in testing. With `--dir` granted, real files (including a 9 MB file exercised through the streaming code path) serve correctly.
3. **No real client IP or TLS SNI hostname.** The host already accepted the TCP connection and terminated TLS before ever invoking the guest, and the `wasi:http` incoming-request type has no field for either. `ConnectionInfo` gets a placeholder address and `sni_hostname: None`.
## Dependency notes
- The static-file/directory-listing controllers that ship with `App` depend on the [`file-ext`](https://crates.io/crates/file-ext) crate for path handling. `file-ext` 12.1.0 added `wasm32` support (path separator, temp folder, and a `remove_dir_all`-based recursive delete in place of shelling out to `rm`, which has no equivalent in a WASI guest) — an older `file-ext` version will fail to compile for this target.
- `rustls`'s own `aws-lc-rs` crypto-backend feature compiles C/asm that doesn't build for `wasm32-wasip2`. The root `Cargo.toml` moves `rustls`/`webpki-roots` into a `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` table so enabling `http-client` (or `sso`/`secrets`/`storage-s3`/`storage-azure`, which depend on it) on `wasm32` never pulls that broken dependency in — the wasm32 build gets its TLS from the host instead, via the `outgoing-handler` interface.
## What's still open
- An automated CI job that builds the component and drives a `wasmtime serve` smoke test in scripted form (the manual verification above, scripted).
- Verification against hosts other than `wasmtime serve` (Spin, Fastly Compute) — architecturally expected to behave the same way, but untested.
- A documented, supported answer for stateful middleware under per-request instantiation (a host-provided KV binding, most likely) — currently just a documented limitation, not a workaround.
See `spec/WASM_SHIM.md` in the repository for the complete, itemized history of what was built and verified.
---
# API Reference
<https://rws8.tech/reference/api/>
Full rustdoc documentation is published at [docs.rs/rust-web-server](https://docs.rs/rust-web-server).
This page is a quick-reference index — click through to docs.rs for the complete method-level API. Module paths are relative to `rust_web_server::`; a feature in parentheses means the item is compiled only with that Cargo feature.
## Application layer
| Type / Trait | Module | Description |
|---|---|---|
| `Application` | `application` | Trait implemented by all app types: `execute(&Request, &ConnectionInfo) -> Result`. `runs_inline(request, connection)` (default `false`) lets the async servers run a request on the event loop instead of the blocking pool — only for work that never waits. See [Async handlers](/building-apps/async-handlers/) |
| `AsyncResponse` | `application` (`http2`) | Return type of the optional `Application::execute_async`, awaited on the connection's task instead of holding a blocking-pool thread |
| `Controller` | `controller` | Trait with `is_matching(request, connection)` and `process(request, response, connection)`. See [Controllers](/building-apps/controllers/) |
| `App` | `app` | Zero-config application with all built-in controllers. `App::with_config(ServerConfig)` pins it to a fixed config (no env reads per request — the lock-free pattern for parallel tests) |
| `AppWithState` | `state` | State-aware app with `.get/.post/.put/.patch/.delete` route builders; state shared as `Arc`. Entry point `App::with_state(S)`; `.with_config(ServerConfig)` pins the fallback `App`. See [State](/building-apps/state/) |
| `AsyncAppWithState` | `async_state` (`http2`) | Same with `async fn` handlers. Entry point `App::with_async_state(S)` |
| `Middleware` | `middleware` | Layer trait: `handle(request, connection, next)`. `runs_inline` / `is_passthrough` (default `false`) declare a layer non-blocking or a no-op for a request. See [Middleware](/building-apps/middleware/) |
| `WithMiddleware` | `middleware` | Wraps any `Application` with a layer stack; `app.wrap(layer)`, first-pushed is outermost |
| `Container` | `di` | `TypeId`-keyed dependency injection container: `register::`, `provide::<dyn Trait>`, `*_named`, `get`. Pass it directly to `App::with_state(container)` — not `into_arc()`, which double-wraps. See [Dependency injection](/building-apps/dependency-injection/) |
| `TeraEngine` | `template` (`tera`) | Jinja2-style templates: `from_dir`, `from_raw`; global `template::init(dir)` / `template::render(name, &ctx)` / `template::reload()` (also run on `SIGHUP`). See [Templates](/building-apps/templates/) |
## Macros
| Item | Module | Description |
|---|---|---|
| `routes!` | `macros` | Declarative routing table — builds `AppWithState`, `AsyncAppWithState` or `Router` from `METHOD "path" => handler` lines. See [Routing](/building-apps/routing/) |
| `#[route]`, `#[get]`, `#[post]`, `#[put]`, `#[patch]`, `#[delete]` | crate root (`macros`) | Attribute macros annotating a handler with its method and path |
| `#[derive(FromRequest)]` | crate root (`macros`) | Derives `FromRequest` for a named-field struct, extracting fields in order and stopping at the first error |
| `#[derive(JsonSchema)]` | crate root (`macros`) | Derives `schema::JsonSchema` for a named-field struct or unit enum, following its `#[serde]` renames (18.0.0). See [Typed tools](/mcp/tools/#typed-tools) |
| `#[derive(Validate)]` | crate root (`macros`) | Derives `Validate` from `#[validate(length, range, email, required, url)]` field annotations |
| `#[derive(Config)]` | crate root (`macros`) | Typed env-var binding: generates `load() -> Result`; `#[config(env, default, prefix)]`. See [Config binding](/features/config-binding/) |
| `#[derive(Model)]` | crate root (`macros` + a `model-*` feature) | Maps a struct to a table (`#[table]`, `#[primary_key(auto_increment)]`, `#[column]`, `#[ignore]`) and generates `T::repository(&pool)` / `T::query(&pool)` |
## HTTP primitives
| Type / Trait | Module | Description |
|---|---|---|
| `Request` | `request` | Parsed HTTP request: method, URI, version, headers, body bytes. `request.header(name)` is a case-insensitive, allocation-free lookup; `Request::is_supported_method` checks a method token |
| `Response` | `response` | HTTP response: status code, headers, body as `Vec`. `Response::json(status, bytes)` / `Response::text(status, &str)` are one-line builders; `response.header(name)` looks a header up |
| `STATUS_CODE_REASON_PHRASE` | `response` | Every HTTP status code as a typed constant |
| `Header` | `header` | Name/value pair (`Cow<'static, str>` fields since 18.0.0; build with `Header::new(name, value)`, read via deref); constants for all standard header names. See [Migrating to 18.0](/reference/migrating-18/) |
| `HTTP` | `http` | Version constants; `HTTP::is_supported_version` |
| `MimeType` | `mime_type` | MIME type constants and detection from a file extension |
| `Range` / `ContentRange` | `range` | Byte ranges and the body parts of a `Response` (bytes, files, multipart) |
| `URL` | `url` | Parse and build URLs; percent-encode/decode |
| `FormUrlEncoded` | `body::form_urlencoded` | Parses `application/x-www-form-urlencoded` bodies. See [Forms & uploads](/building-apps/forms-uploads/) |
| `FormMultipartData` | `body::multipart_form_data` | Parses `multipart/form-data` uploads |
| `Json` | `json` (`serde`) | Serde JSON extractor and responder. See [JSON](/building-apps/json/) |
| `CookieJar` | `cookie` | Parses the `Cookie` request header. See [Cookies](/building-apps/cookies/) |
| `SetCookie` | `cookie` | Builds `Set-Cookie` values with all RFC 6265 attributes |
| `signed_cookie` / `verify_signed_cookie` | `cookie` (`crypto`) | HMAC-SHA256 tamper-evident cookie value `"<value>.<hex-signature>"`; verification returns `None` on tampering |
| `encrypted_cookie` / `decrypt_cookie` | `cookie` (`crypto`) | AES-256-GCM confidential cookie value; fresh nonce per call, `None` on tampering or wrong key |
## Routing
| Type / Trait | Module | Description |
|---|---|---|
| `Router` | `router` | Path-pattern router with `:param` and `*wildcard`; `.with_host(name)` restricts it to one virtual host; `.matches(request, connection)` tells whether `handle()` would run a handler |
| `PathParams` | `router` | Extracted path parameters; `.get(name) -> Option<&str>` |
| `ConnectionInfo` | `server` | Client/server IP, port, and SNI hostname per connection |
| `VirtualHostConfig` | `virtual_host` | Per-domain certificate config `{ domain, cert_file, key_file }` for SNI. See [Virtual hosting](/features/virtual-hosting/) |
## Typed extractors and errors
| Type / Trait | Module | Description |
|---|---|---|
| `FromRequest` | `extract` | Trait for typed extraction from a request, returning a ready error response on failure. See [Extractors](/building-apps/extractors/) |
| `Body` | `extract` | Raw request body bytes (never fails) |
| `Storage::put_stream` / `S3_PART_SIZE` | `storage` | Store a `Read` stream: S3 multipart upload (8 MiB parts), local temp file + rename; default buffers (18.0.0) |
| `request::Body` | `request` | `Request.body` (18.0.0): bytes, or a stream (`Body::stream`, `body_bytes()`, `into_reader()`); derefs to `&[u8]`. Not the `extract::Body` extractor |
| `BodyText` | `extract` | UTF-8 request body (returns 400 on invalid UTF-8) |
| `Query` | `extract` | Parsed query string as `HashMap` |
| `RequestHeaders` | `extract` | All request headers with case-insensitive `.get(name)` |
| `RequestId` | `extract` / `request_id` | The ID `RequestIdLayer` put on the request; empty string without it (never fails) |
| `IntoResponse` | `error` | Trait for mapping error enums to `Response` values. See [Error handling](/building-apps/error-handling/) |
| `AppError` | `error` | Built-in error variants: `BadRequest`, `NotFound`, `Internal`, etc. |
| `Validate` | `validate` | Field-level validation trait. See [Validation](/building-apps/validation/) |
| `ValidationErrors` | `validate` | Collects every validation failure before returning |
| `Validated` | `validate` | Extractor that extracts then validates: 400 on extraction failure, 422 with a JSON error body on validation failure |
| `is_email` / `is_url` | `validate` | Format checks used by the derive; callable directly |
## Server and configuration
| Type / Trait | Module | Description |
|---|---|---|
| `Server` | `server` | TCP listener, thread pool, TLS acceptor, and request loop. `Server::continue_response` / `expectation_failed_response` implement `Expect: 100-continue` / 417 on HTTP/1.1 |
| `server::REQUEST_HEAD_TIMEOUT` | `server` | 30 s: a request head must complete within this of its first byte, else `408` |
| `ServerConfig` | `server_config` | Typed snapshot of per-instance config (CORS, CSP, `client_hints`, `static_cache_control`, log format). `ServerConfig::current()` is the shared per-request snapshot; call `server_config::invalidate()` after a direct `std::env::set_var` |
| `ConfigSnapshot` | `config_reload` | Hot-reloadable values, read with `config_reload::current()`. See [Hot reload](/features/hot-reload/) |
| `config_reload::reload` / `config_reload::on_reload` | `config_reload` | Re-read `rws.config.toml` (also on `SIGHUP` and `POST /admin/config/reload`); register a closure run after every reload |
| `ConfigReport` | `config_check` | Result of `config_check::check_file` / `check_str` (what `rws --check` runs): `errors`, `warnings`, `.is_ok()`, `.render(path)`, `.render_json(path)`. See [Config file](/configuration/config-file/) |
| `get_max_body_size` | `entry_point` | Reads `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (default `0`, unlimited); larger requests get `413` before buffering |
| `get_spa_fallback` / `get_spa_fallback_exclude_prefixes` | `entry_point` | Read `RWS_CONFIG_SPA_FALLBACK*`. See [Static files](/features/static-files/) |
| `FromEnvStr` | `config_binding` | Implement for custom field types used with `#[derive(Config)]` |
| `secrets::resolve` / `resolve_env_vars` | `secrets` (`secrets`) | Resolve `vault://`, `aws-sm://` and `azkv://` references; `Server::setup()` rewrites matching `RWS_*` env vars at startup and fails hard on errors. See [Secrets](/features/secrets/) |
| `SecretsError` | `secrets` (`secrets`) | Error type for secret resolution |
| `StaticResourceController::STREAM_THRESHOLD` | `app::controller::static_resource` | 256 KiB: larger static files are streamed from disk instead of read into memory |
| `StaticResourceController::render_directory_listing` | `app::controller::static_resource` | Renders the listing `App` serves for a directory without `index.html` |
| `DirectoryListingAssetsController` | `app::controller::directory_listing` | Serves the listing's CSS/JS at `/rws-directory-listing.{css,js}` (same-origin, so the default CSP allows them); a file on disk overrides the built-in |
## Middleware
| Type / Trait | Module | Description |
|---|---|---|
| `RateLimiter` | `rate_limit` | Sliding-window per-key limiter; `rate_limit::global()` for the singleton; `.retry_after()` / `.limits()`. See [Rate limiting](/features/rate-limiting/) |
| `RedisRateLimiter` | `rate_limit` | Fixed-window limiter shared across instances through Redis; `from_env()` |
| `RateLimitLayer` | `middleware` | Enforces the global rate limiter per client IP; `429` with `Retry-After` |
| `CacheLayer` | `cache` | In-memory TTL response cache for GET (`CacheLayer::memory(n).ttl(s).vary_by_header(h)`); skips `no-store`/`private`/`no-cache`, `Range` and out-of-key `Vary` responses. See [Caching](/features/caching/) |
| `MetricsLayer` | `metrics` | Per-route Prometheus counters and latency histograms. See [Metrics](/features/metrics/) |
| `RewriteLayer` | `rewrite` | Request/response header, URI, body, and status rewriting; `.request_uri_regex_rewrite` needs `rewrite-regex`. See [Rewrite](/features/rewrite/) |
| `BasicAuthLayer` | `auth` (`auth`) | HTTP Basic authentication via a closure or `from_htpasswd_file` (plain or rws's `{SHA256}` scheme — not Apache `{SHA}`/`$apr1$`/bcrypt). See [Auth](/features/auth/) |
| `JwtLayer` | `auth` (`auth`) | HS256 Bearer JWT validation; `::rs256`/`::es256` verify against a static public key (`auth-asymmetric`) |
| `ForwardAuthLayer` | `auth::forward` (`auth`) | Delegates allow/deny to an external HTTP service; verbatim pass-through on non-2xx, 502 if unreachable |
| `IpFilter` | `ip_filter` | IPv4/IPv6 address or CIDR allowlist/denylist; `ip_filter::is_valid_entry` checks an entry. See [IP filter](/features/ip-filter/) |
| `Blocklist` | `blocklist` | Runtime IP blocklist: `blocklist::global().block(ip)` / `.unblock` / `.is_blocked` / `.list` / `.clear` |
| `BlocklistLayer` | `blocklist` | Answers `403` for blocked client IPs |
| `MaintenanceLayer` / `MAINTENANCE_MODE` | `maintenance` | While the `AtomicBool` is `true`, answers `503` for everything but `/healthz` and `/readyz` |
| `RequestLog` | `request_log` | In-memory buffer of recent requests (`request_log::global()`, capacity 1000); `.recent(n)` / `.recent_errors(n)` |
| `LogLayer` | `request_log` | Records each request into `RequestLog` |
| `RequestIdLayer` | `request_id` | Correlation-ID middleware — echoes or generates `X-Request-Id` (`generate_request_id()`, UUID-v4-shaped, not crypto-random). See [Request ID](/features/request-id/) |
| `CsrfLayer` | `csrf` (`csrf`) | Double-submit cookie CSRF protection; 403 on mismatch. See [CSRF](/features/csrf/) |
| `CsrfToken` | `csrf` (`csrf`) | Extractor for the current token in a GET handler; implements `Display` |
| `TimeoutLayer` | `timeout` | Wraps a whole `Application` with a deadline (`504`). See [Timeouts](/features/timeouts/) |
| `with_timeout` / `with_timeout_state` / `with_timeout_async` | `timeout` | Per-handler timeouts. The sync ones bound the client's wait but can't cancel the work; `with_timeout_async` (`http2`) cancels the future |
| `FeatureStore` | `feature` | Runtime feature toggles: `feature::global().set(name, bool)` / `.is_enabled` / `.list` |
## Authentication and crypto
| Type / Trait | Module | Description |
|---|---|---|
| `build_jwt` / `verify_jwt` | `auth` (`auth`) | Sign and verify HS256 JWTs |
| `Claims` | `auth` (`auth`) | Decoded JWT claims: `sub`, `exp`, raw JSON payload |
| `verify_jwt_rs256` / `verify_jwt_es256` | `auth` (`auth-asymmetric`) | Standalone RS256/ES256 verification backing `JwtLayer::rs256`/`::es256` |
| `RsaPublicKey` | `rsa_key` (`auth-asymmetric` / `sso`) | In-crate RSA public key (PEM, SPKI/PKCS#1 DER or components; 2048–8192 bits); `.verify_pkcs1v15_sha256(message, signature)` |
| `hash_password` / `verify_password` | `crypto` (`crypto`) | Argon2id hashing (PHC string, salt embedded) and constant-time verification |
| `generate_token` | `crypto` (`crypto`) | CSPRNG hex token for reset links and API keys |
| `CryptoError` | `crypto` (`crypto`) | Error type for password hashing |
| `verify_webhook_signature` | `webhook` (`webhook`) | Verifies an inbound webhook HMAC for a `WebhookProvider`. See [Webhooks](/features/webhooks/) |
| `WebhookProvider` | `webhook` (`webhook`) | `GitHub`, `Shopify`, `Stripe` (300 s default replay tolerance) |
| `verify_github_signature` / `verify_shopify_signature` / `verify_stripe_signature` / `verify_stripe_signature_with_tolerance` | `webhook` (`webhook`) | Per-provider functions when the provider is known at the call site |
## SSO
| Type / Trait | Module | Description |
|---|---|---|
| `OidcAuth` | `sso` (`sso`) | OAuth2/OIDC middleware: intercepts `/auth/login`, `/auth/callback`, `/auth/logout`; `OidcAuth::claims(req)` reads the claims. See [SSO](/features/sso/) |
| `OidcConfig` | `sso` (`sso`) | Client configuration; presets `google`, `microsoft`, `github`, `okta`, `auth0`, `keycloak`, and `from_env()` |
| `OidcProvider` | `sso` (`sso`) | Provider endpoints; same presets plus `OidcProvider::discover(issuer)` |
| `OidcClaims` | `sso` (`sso`) | Standard OIDC claims from a verified `id_token` or UserInfo response (no `groups`) |
| `JwksCache` | `sso` (`sso`) | JWKS key cache; `verify_jwt(token, &VerifyOptions)` checks RS256/ES256 signature, `exp`, `iss`, `aud`; refetches once on a key miss |
| `VerifyOptions` | `sso` (`sso`) | `audience`, `issuer`, `leeway_secs` for `JwksCache::verify_jwt` |
| `OidcClient` | `sso` (`sso`) | `authorization_url`, `exchange_code`, `fetch_user_info` |
| `TokenResponse` | `sso` (`sso`) | Token endpoint response returned by `exchange_code` |
| `PkceVerifier` | `sso` (`sso`) | RFC 7636 code verifier; `.challenge()` |
| `PkceChallenge` | `sso` (`sso`) | S256 code challenge for the authorization URL |
| `AuthServer` | `sso` (`sso-server`) | rws as an OAuth 2.0 Authorization Server (`/oauth/token`, `/oauth/authorize`, discovery). Tokens are HS256, so `jwks.json` is always empty; `/oauth/authorize` trusts an existing app session, with no built-in login page |
| `AuthServerConfig` | `sso` (`sso-server`) | `issuer`, `signing_secret`, access/refresh token lifetimes, `clients` |
| `ClientStore` | `sso` (`sso-server`) | Registry of OAuth clients: `ClientStore::new().add(client).get(id)` (no `from_env`) |
| `OAuthClient` | `sso` (`sso-server`) | One client; `client_secret: None` is a public PKCE-only client |
| `GrantType` | `sso` (`sso-server`) | `ClientCredentials`, `AuthorizationCode`, `RefreshToken` |
| `SamlSp` | `sso::saml` (`sso-saml`) | SAML 2.0 Service Provider middleware (`/saml/metadata`, `/saml/login`, `/saml/acs`, `/saml/logout`). Verifies RSA-SHA256 signatures byte-exact against the transmitted XML (not full C14N — fails closed), requires exactly one `Assertion`, rejects `EncryptedAssertion`; unsigned HTTP-POST `AuthnRequest`s; local-only logout. See [SAML](/features/saml/) |
| `SamlConfig` | `sso::saml` (`sso-saml`) | `sp_entity_id`, `sp_acs_url`, `idp_metadata`, `sessions` |
| `SamlIdpMetadata` | `sso::saml` (`sso-saml`) | Parsed IdP metadata (`entity_id`, `sso_url`, `signing_key`); `from_file` / `from_url` / `from_str` |
| `AttributeMap` | `sso::saml` (`sso-saml`) | Maps IdP attribute names to claim fields |
| `SamlClaims` | `sso::saml` (`sso-saml`) | `name_id` plus `attributes: HashMap`; read with `SamlSp::claims` / `name_id` / `attr` |
## Sessions
| Type / Trait | Module | Description |
|---|---|---|
| `SessionStore` | `session` | In-memory TTL session store; helpers `session_id_from_request`, `session_cookie`, `destroy_cookie`. See [Sessions](/features/sessions/) |
| `Session` | `session` | One session's data |
| `DbSessionStore` | `session` (a `model-*` feature) | Persistent store on the model-layer `DbPool` (`rws_sessions` table); async methods |
| `RedisSessionStore` | `session` | Persistent store on Redis; `new(addr, password, ttl)` or `from_env()` |
## Real-time protocols
| Type / Trait | Module | Description |
|---|---|---|
| `Sse` | `sse` | Builds a buffered `text/event-stream` response. See [SSE](/features/sse/) |
| `SseEvent` | `sse` | One event: data plus optional `event`, `id`, `retry` |
| `WebSocket` | `websocket` | RFC 6455 handshake and frame read/write. See [WebSocket](/features/websocket/) |
| `Frame` | `websocket` | A WebSocket frame: `Text`, `Binary`, `Ping`, `Pong`, `Close`, `Continuation` |
## MCP
| Type / Trait | Module | Description |
|---|---|---|
| `Encoding` / `Level` | `compression` | Content codings (`gzip`, `br`, `zstd`) and levels; `negotiate(request, preference)`, `encode`, `apply_compression` (18.1.0). See [Compression](/features/compression/) |
| `JsonSchema` / `ObjectSchema` | `schema` | A type's JSON Schema as text (primitives, `Option`, collections, smart pointers built in); `ObjectSchema` builds an object schema (18.0.0) |
| `McpGateway` / `Upstream` | `mcp::gateway` | One MCP endpoint federating upstream MCP servers: `prefix__tool` names, per-caller `allow` rules, upstream credentials kept server-side (18.0.0). See [MCP gateway](/mcp/gateway/) |
| `McpServer` / `McpIcon` | `mcp` | MCP Streamable HTTP server (`POST /mcp`, JSON-RPC 2.0, plus `GET /mcp` SSE); `.tool` / `.typed_tool` (`serde`) / `.resource` / `.prompt`, `.require_bearer`, `.require_bearer_tokens`, `.require_oauth` (`sso`), `.tool_filter`, `.session_ttl` / `.max_sessions` / `.max_sse_clients`. Entry point `McpServer::new(name, version)` or `app.mcp(name, version)`. See [MCP](/mcp/overview/) |
| `McpContent` | `mcp` | Tool result content: `text`, `json`, `image`, `embedded` |
| `McpContext` | `mcp` | Per-session context for `.tool_with_context` handlers: client info, `auth_claims`, progress, cancellation, `sample`, `list_roots` |
| `ToolAnnotations` | `mcp` | `read_only_hint`, `destructive_hint`, `idempotent_hint`, `open_world_hint` for `.tool_annotated` |
## Proxy
| Type / Trait | Module | Description |
|---|---|---|
| `ReverseProxy` | `proxy` | HTTP/1.1 reverse proxy middleware: round-robin with failover (502 when all fail), pooled connections, unbuffered streaming of SSE/chunked/large bodies; `.with_circuit_breaker(Arc<dyn Breaker>)`. See [Reverse proxy](/proxy/reverse-proxy/) |
| `LoadBalancing` | `proxy` | Strategy enum passed to `ReverseProxy::strategy()` |
| `ConnPool` / `Pool` | `proxy` | Per-backend idle-connection pool (`ConnPool = Pool`); `ConnPool::new(max_idle, idle_timeout)`, share with `ReverseProxy::with_pool`. A stale pooled connection is retried once only for idempotent methods |
| `UpstreamTls` | `proxy` | TLS settings for upstreams: a PEM CA file instead of WebPKI and/or a client certificate (`UpstreamTls::new(ca, client)`; `tls_ca_file`, `tls_client_cert` on `ReverseProxy`/`H2ReverseProxy`/`GrpcProxy`, `[[upstream]] tls_ca_file`/`tls_client_cert_file`/`tls_client_key_file`); unloadable → 502 |
| `H2ReverseProxy` | `proxy` (`http2`) | HTTP/2 upstream proxy: `h2://` plain, `h2s://`/`https://` TLS; pooled connections, bodies streamed both ways, trailers kept (18.3.0) |
| `Trailers` | `response` | Trailer fields sent after a body (`Response::trailers`, HTTP/2 and HTTP/3): a shared slot a streamed body's producer fills before it ends (18.3.0) |
| `GrpcWebLayer` | `proxy` (`http2`) | Translates gRPC-web (`application/grpc-web[-text]`) to gRPC for the layers after it and the response back, trailers as the body's last frame (18.3.0). See [gRPC-web](/proxy/grpc-proxy/#grpc-web-from-browsers) |
| `GrpcProxy` | `proxy` (`http2`) | gRPC proxy (wraps `H2ReverseProxy`, filters on `application/grpc*`). See [gRPC proxy](/proxy/grpc-proxy/) |
| `TcpProxy` | `tcp_proxy` | Standalone L4 TCP proxy with round-robin backends. See [TCP proxy](/proxy/tcp-proxy/) |
| `UdpProxy` | `udp_proxy` | Standalone request-reply UDP proxy. See [UDP proxy](/proxy/udp-proxy/) |
| `WsProxy` | `ws_proxy` | WebSocket proxy, `ws://` and `wss://` (`http-client` or `http2`); `with_live_backends` for health-aware selection (503 when empty). See [WebSocket proxy](/proxy/websocket-proxy/) |
| `CanaryLayer` | `canary` | Weighted traffic splitting (smooth weighted round-robin); `Clone` shares state, `.update()` changes weights live. See [Canary](/proxy/canary/) |
| `WeightedBackend` | `canary` | One weighted backend URL |
| `WeightedPool` | `canary` | A weighted group whose members come from a `BackendPool` |
| `CircuitBreaker` | `circuit_breaker` | Per-backend Closed/Open/HalfOpen breaker in memory; `circuit_breaker::global()`. See [Circuit breaker](/proxy/circuit-breaker/) |
| `RedisCircuitBreaker` | `circuit_breaker` | Same state machine shared through Redis; methods return `Result`; `from_env()` |
| `Breaker` | `circuit_breaker` | Object-safe trait over both breakers, taken by `ReverseProxy::with_circuit_breaker` |
| `RetryLayer` | `circuit_breaker` | Re-dispatches a request whose status is retryable (default 502/503/504); push it before the proxy |
| `BackendPool` | `service_discovery` | Backend list refreshed by a background thread. See [Service discovery](/proxy/service-discovery/) |
| `DiscoverySource` | `service_discovery` | `Static`, `EnvPrefix`, `File`, `Dns`, `DnsSrv`, `Consul`, `Docker`, `EtcdWatch` |
| `KubernetesIngressWatcher` | `ingress` | Resyncs and watches Ingress resources; `.from_service_account()` connects in-cluster (`http-client`/`http2`). See [Kubernetes Ingress](/deployment/kubernetes-ingress/) |
| `IngressRouter` | `ingress` | `Application` routing requests by the watched Ingress rules |
| `IngressRule` | `ingress` | One parsed Ingress rule |
| `PathType` | `ingress` | `Exact`, `Prefix`, `ImplementationSpecific` |
| `ProxyConfig` | `proxy_config` | Parsed `[[route]]`/`[[upstream]]` config; `ProxyConfig::is_proxy_mode()`, `.validate(&mut ConfigReport)`. See [Config-driven proxy](/proxy/config-driven/) |
| `ConfigDrivenApp` | `proxy_config` | The app `proxy_config::build_from_file()` returns: first-match router with per-route middleware and per-upstream `strategy` (`round_robin`, `random`, `ip_hash`, `least_connections`); `.with_config(ServerConfig)` |
| `ReloadOutcome` | `proxy_config` | Result of `ConfigDrivenApp::reload_from_str` / `reload_from_file` (validated first; the running routes stay on errors) |
## HTTP client
| Type / Trait | Module | Description |
|---|---|---|
| `Client` | `http_client` | Synchronous HTTP/1.1 client; follows redirects, 30 s default timeout. HTTPS needs `http-client` or `http2`. See [HTTP client](/building-apps/http-client/) |
| `RequestBuilder` | `http_client` | `.header`, `.body`, `.body_text`, `.body_json`, `.form`, `.timeout_ms`, `.send()` |
| `Response` | `http_client` | `.status()`, `.is_success()`, `.header(name)`, `.bytes()`, `.text()`, `.json::()` (`serde`) |
| `AsyncClient` | `http_client` (`http2`) | Async client with `.send().await` |
| `AsyncRequestBuilder` | `http_client` (`http2`) | Async request builder (same methods, including `.form`) |
| `HttpClientError` | `http_client` | Error type; implements `std::error::Error` |
## Observability
| Type / Trait | Module | Description |
|---|---|---|
| `Log` | `log` | Access log lines: `Log::json` / `Log::combined` return one line; `Log::log_access` buffers per thread; `Log::flush_access_log()` writes now. See [Observability](/deployment/observability/) |
| `OtelLayer` | `otel` | HTTP server root span, W3C `traceparent` propagation, stdout or OTLP export. See [Tracing](/features/tracing/) |
| `otel::setup` / `setup_from_env` / `setup_with_exporter` | `otel` | Initialize the global tracer once at startup (`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_SAMPLER_ARG`) |
| `otel::shutdown` / `otel::flush` | `otel` | Flush buffered spans; call `shutdown()` before exit |
| `otel::span` / `otel::client_span` | `otel` | Start an Internal / Client child span under the active span |
| `otel::current_traceparent` | `otel` | `traceparent` of the innermost active span, for outbound calls |
| `Span` | `otel` | RAII child span: `.set_attribute`, `.set_error`, `.record_error`, ends on drop |
| `SpanKind` | `otel` | `Internal`, `Server`, `Client` |
| `AttributeValue` | `otel` | `String`, `Int`, `Float`, `Bool`, with `From` impls |
| `TracingConfig` | `otel` | Service name, exporter, sample rate, batch size |
| `ExporterConfig` | `otel` | Built-in exporter choice |
| `Exporter` | `otel` | Trait for span exporters, accepted by `setup_with_exporter` |
| `StdoutExporter` / `OtlpHttpExporter` | `otel` | Built-in exporters; `OtlpHttpExporter::new(endpoint, service_name, service_version)` |
| `TraceContext` | `otel` | Parsed `traceparent`: `TraceContext::parse`, `.as_header(span_id)` |
| `SpanData` | `otel` | A finished span ready for export |
## Database / ORM
All model-layer types need `model-sqlite`, `model-postgres` or `model-mysql` (each implies `http2`; they combine).
| Type / Trait | Module | Description |
|---|---|---|
| `DbPool` | `model` | Async `sqlx` pool, one variant per compiled-in backend: `new(DbConfig)`, `from_env()`, `memory()` (SQLite `:memory:`); `execute`, `query`, `begin`, `transaction`, `migrate`. See [Database](/database/overview/) |
| `DbTransaction` | `model` | Open transaction from `DbPool::begin`; `commit` / `rollback` |
| `DbConfig` | `model` | Connection settings; `from_env()` reads `RWS_DB_*` |
| `Backend` | `model` | `Sqlite`, `Postgres`, `MySql` (compiled-in only); `Backend::parse`; pick it explicitly only with several backends compiled in |
| `Repository` | `model` | Async CRUD trait (`find_by_id`, `find_all`, `save`, `delete_by_id`, `count`, …). See [Repository](/database/repository/) |
| `ModelRepository` | `model` | `Repository` implementation from `T::repository(&pool)`; `save` inserts when the key is 0, else updates |
| `QueryBuilder` | `model` | Fluent query builder: `where_eq`, `filter`, `order_by`, `limit`, `fetch_all`, `count`, `paginate`, `paginate_after`. See [Query builder](/database/query-builder/) |
| `HasMany` / `HasOne` / `BelongsTo` | `model` | Explicit-load relations with `.load(&pool).await` — no hidden N+1. See [Relations](/database/relations/) |
| `MigrationStatus` | `model` | Applied/pending state from `DbPool::migration_status`. See [Migrations](/database/migrations/) |
| `Page` | `pagination` (re-exported from `model`) | Offset page: totals, `has_next`/`has_prev`, `.map(f)`, `.link_header(base_url)`; usable without the model layer. See [Pagination](/database/pagination/) |
| `CursorPage` | `pagination` (re-exported from `model`) | Keyset page with `next_cursor`, `.link_header(base_url, cursor_param)` |
## Background work
| Type / Trait | Module | Description |
|---|---|---|
| `Scheduler` | `scheduler` | Recurring tasks: `.every(d, f)`, `.after(d, f)`, `.cron(expr, f)`. See [Scheduler](/features/scheduler/) |
| `CronSchedule` | `scheduler` | Parsed six-field cron expression (`sec min hour day month weekday`) |
| `Job` | `jobs` (`jobs`) | Job trait, implemented for `Fn() -> Result<(), String> + Send` closures. See [Jobs](/features/jobs/) |
| `JobQueue` | `jobs` (`jobs`) | In-memory worker pool with retry and exponential backoff; not crash-safe |
| `PersistentJobQueue` | `jobs` (`jobs` + a `model-*` feature) | Crash-safe queue in a `rws_jobs` table; `(job_type, payload)` jobs dispatched to registered handlers |
## Storage and mail
| Type / Trait | Module | Description |
|---|---|---|
| `Storage` | `storage` | Trait: `put`, `get`, `delete`, `url`. See [Storage](/features/storage/) |
| `StorageError` | `storage` | Error type for storage backends |
| `LocalStorage` | `storage` (`storage-local`) | Files under a root directory; rejects `..`; `.with_base_url(prefix)` |
| `S3Storage` | `storage` (`storage-s3`) | S3-compatible storage with SigV4, no AWS SDK; static keys or IRSA / ECS / IMDSv2 credentials |
| `S3Config` | `storage` (`storage-s3`) | Bucket, region, endpoint, credentials; `from_env()` |
| `AzureBlobStorage` | `storage` (`storage-azure`) | Azure Blob Storage with Shared Key signing or Managed Identity, no Azure SDK |
| `AzureBlobConfig` | `storage` (`storage-azure`) | Account, container, endpoint, credentials; `from_env()` |
| `Mailer` | `mailer` (`mailer`; STARTTLS/SMTPS also need `http-client` or `http2`) | SMTP client; `Mailer::from_env()` reads `RWS_SMTP_*`; `.send(&email)`. See [Email](/features/email/) |
| `Email` | `mailer` (`mailer`) | An RFC 5322 message; `Email::builder()` |
| `EmailBuilder` | `mailer` (`mailer`) | `.to`, `.cc`, `.bcc`, `.reply_to`, `.subject`, `.text`, `.html`, `.build()` (`multipart/alternative` when both bodies are set) |
| `SmtpTls` | `mailer` (`mailer`) | `None` (25), `Starttls` (587, default), `Smtps` (465) |
| `MailerError` | `mailer` (`mailer`) | `MissingConfig`, `Io`, `Smtp`, `Build` |
## Other
| Type / Trait | Module | Description |
|---|---|---|
| `TestClient` | `test_client` | In-process test client — dispatches requests without a TCP socket. See [TestClient](/testing/test-client/) |
| `OpenApiConfig` / `build_spec` | `openapi` (`openapi`) | OpenAPI 3.0 from registered routes; `AppWithState::openapi(config)` adds `/openapi.json` and `/docs`. Paths, methods and path parameters only — no body schemas. See [OpenAPI](/features/openapi/) |
| `AcmeConfig` | `acme` (`acme`) | ACME / Let's Encrypt settings; `AcmeConfig::from_env()` reads `RWS_CONFIG_ACME_*`. See [ACME](/features/acme/) |
| `AcmeManager` | `acme` (`acme`) | HTTP-01 provisioning and renewal: `.provision_until_ready().await`, `.run_renewal_loop()` |
:::note[Feature flags]
Items marked with a feature in parentheses compile only with that Cargo feature. See [Features](/getting-started/features/) and `Cargo.toml` for the full list.
:::
---
# Troubleshooting
<https://rws8.tech/reference/troubleshooting/>
## Starting the server
### `unable to set up TCP listener: Permission denied (os error 13)`
Ports below 1024 need privileges. Use a higher port (`--port=7878`), or run
with `sudo` / as Administrator. In a container, the release image runs as a
non-root user, so map a high container port to 80/443 instead.
### `unable to set up TCP listener: Address already in use`
Another process holds the port. Find it:
```bash
sudo lsof -i :7878 # macOS and Linux
sudo fuser 7878/tcp # Linux
```
### `Cannot assign requested address (os error 99)`
The `--ip` you passed isn't assigned to any local interface. List them with
`ip addr` (Linux) or `ifconfig`, or bind to all of them with `--ip=0.0.0.0`.
### `sudo rws` says `command not found`
`~/.cargo/bin` (or `/usr/local/bin`) isn't on root's `PATH`. Use the full
path: `sudo ~/.cargo/bin/rws`.
## Reaching the server
### Other machines can't connect
- Bound to `127.0.0.1`? That only accepts local connections. The default is
`0.0.0.0`; pass `--ip=0.0.0.0` if you overrode it.
- `Connection refused` from another host with the right IP: a firewall is
blocking the port.
### Serving on IPv6
`rws --ip=::` listens on IPv6 (and IPv4-mapped addresses). Link-local
`fe80::…` addresses need a zone id that browsers rarely accept; connect with
the machine's IPv4 address or a global IPv6 address instead.
### HTTPS, HTTP/2 or HTTP/3 isn't working
No certificate is configured, so `rws` falls back to plain HTTP/1.1. Provide
one ([HTTPS / TLS](/features/https-tls/)) or let `rws` get one
([ACME](/features/acme/)):
```bash
rws --tls-cert-file=/path/to/cert.pem --tls-key-file=/path/to/key.pem
```
### `TLS setup failed: failed to read cert file …`
The path is wrong, the process can't read it, or the file isn't PEM. Both the
certificate (chain) and the key must be PEM.
### The browser warns about the certificate
Self-signed certificates aren't trusted. For local development add yours to
the system trust store (or use `mkcert`); in production use a public CA,
for example through [ACME](/features/acme/).
### Malformed-request errors (`400`)
The server received something that isn't an HTTP request — a port scanner,
TLS sent to a plain-HTTP port, or garbage. The client gets a `400 Bad Request`;
it's safe to ignore.
### A directory shows a file listing
A directory without `index.html` is shown as a listing. Add an `index.html` to
serve that instead.
## Logs and metrics
### I want the Combined Log Format, not JSON
Set `RWS_CONFIG_LOG_FORMAT=combined` (or `log_format = "combined"` in
`rws.config.toml`); `off` disables the access log. JSON is the default. Lines
are buffered and written within 100 ms — see
[Observability](/deployment/observability/).
### Scraping metrics
`GET /metrics` serves Prometheus text format; see [Metrics](/features/metrics/)
for the metric names and a scrape config.
## Kubernetes
### Probes fail with `Connection refused`
The kubelet probes the pod IP, so `rws` must not be bound to `127.0.0.1`.
The default `0.0.0.0` works; check for an `ip = "127.0.0.1"` override.
### `/readyz` returns `503` right after start
Expected: it returns `503` until startup finishes, then `200`. If it stays at
`503`, startup failed — check the logs. Give slow starts an
`initialDelaySeconds`:
```yaml
readinessProbe:
httpGet:
path: /readyz
port: 7878
initialDelaySeconds: 5
periodSeconds: 5
```
### Requests are dropped on `SIGTERM`
`rws` stops accepting on `SIGTERM` and finishes in-flight requests. Requests
can still arrive while the load balancer hasn't noticed yet; delay
termination with a `preStop` sleep (Kubernetes 1.30+ — the release image has
no shell for an `exec` hook):
```yaml
lifecycle:
preStop:
sleep:
seconds: 5
```
More in [Kubernetes](/deployment/kubernetes/).
## Building from source
### `linker 'cc' not found`
Install a C toolchain (needed by `aws-lc-rs` and other native dependencies):
```bash
sudo apt-get install build-essential # Debian/Ubuntu
sudo dnf group install "Development Tools" # Fedora/RHEL
sudo pacman -S base-devel # Arch
```
### `found crate … compiled by an incompatible version of rustc`
Stale build artifacts from another toolchain: `cargo clean && cargo build`.
### `error: package … requires rustc 1.88`
The minimum supported Rust version is 1.88: `rustup update stable`.
### `failed open - '…/FETCH_HEAD' is locked: Permission denied`
The Cargo registry was written by `sudo cargo …` once. Fix the ownership
(`sudo chown -R $USER ~/.cargo`) rather than building as root.
---
# Migrating to 18.0
<https://rws8.tech/reference/migrating-18/>
18.0 has a handful of breaking changes; most applications only meet the first and the `Request.body` type. If you are coming from 17.113.0 or earlier, also read [the patch-release changes](#coming-from-171130-or-earlier).
## `Header` fields are `Cow<'static, str>`
`Header { name, value }` used to be two `String`s; now both are
`Cow<'static, str>`, so the standard headers on every response are borrowed
instead of allocated.
```rust
use rust_web_server::header::Header;
// 17.x
let h = Header { name: Header::_LOCATION.to_string(), value: url };
// 18.0 — a &'static str is borrowed, a String is owned
let h = Header::new(Header::_LOCATION, url);
let h = Header::new("X-Static", "fixed");
let h = Header { name: "X-Static".into(), value: "fixed".into() }; // fields are still public
```
Reading:
| 17.x | 18.0 |
|------|------|
| `h.value.as_str()` | `&*h.value` or `h.value.as_ref()` (`as_str` isn't stable on the 1.88 MSRV) |
| `h.value.clone()` (a `String`) | `h.value.to_string()` |
| `h.value.push_str(..)` | `h.value.to_mut().push_str(..)` |
| `h.name == "Content-Type"` | unchanged (`Cow<str>` compares with `&str`) |
To find a request header, prefer `request.header("content-type")` — a
case-insensitive lookup that returns `Option<&Header>` without allocating.
## Default response headers
| Header | 17.x | 18.0 |
|--------|------|------|
| `Accept-CH`, `Critical-CH`, client hints in `Vary` | always | only with `RWS_CONFIG_CLIENT_HINTS=true` |
| `Vary` | `Origin` + the client-hint headers | `Origin` |
| `Cache-Control` on static files | `no-store, …` | `no-cache` (`RWS_CONFIG_STATIC_CACHE_CONTROL`) |
| `Last-Modified` on static files | non-standard | standard HTTP date |
| `Date` | — | on every response |
| `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` | always | only with `RWS_CONFIG_SECURITY_HEADERS=true` |
| `Content-Security-Policy` | `default-src 'self'` unless `RWS_CONFIG_CSP` | `RWS_CONFIG_CSP`, or the default with `RWS_CONFIG_SECURITY_HEADERS=true` |
| `Date-Unix-Epoch-Nanos`, `Last-Modified-Unix-Epoch-Nanos` | always | only with `RWS_CONFIG_NANOS_HEADERS=true` |
| `Access-Control-Allow-Origin` (+ `-Credentials: true`) for any `Origin` | always (`cors_allow_all` defaulted to `true`) | only with `RWS_CONFIG_CORS_ALLOW_ALL=true` or a listed origin |
What it means:
- **Browsers now keep static files** and revalidate them (`If-None-Match` /
`If-Modified-Since` → `304`). To get the old behaviour set
`RWS_CONFIG_STATIC_CACHE_CONTROL=` (empty). For fingerprinted asset names,
set something like `public, max-age=31536000, immutable`. Responses from
your handlers still default to `no-store, …`.
- **Security headers**: to keep sending `X-Frame-Options`,
`Referrer-Policy`, `Permissions-Policy` and the default CSP, set
`RWS_CONFIG_SECURITY_HEADERS=true` (`security_headers = true`). A CSP set
with `RWS_CONFIG_CSP` is sent either way.
- **Nanosecond timestamps**: clients that read `Date-Unix-Epoch-Nanos` or
`Last-Modified-Unix-Epoch-Nanos` need `RWS_CONFIG_NANOS_HEADERS=true`
(`nanos_headers = true`).
- **Client hints**: if your app reads `Sec-CH-*` request headers, set
`RWS_CONFIG_CLIENT_HINTS=true` (or `client_hints = true` in
`rws.config.toml`).
- **CORS**: a browser app on another origin that calls `rws` needs its origin
in `RWS_CONFIG_CORS_ALLOW_ORIGINS` (`[cors] allow_origins`), plus
`RWS_CONFIG_CORS_ALLOW_CREDENTIALS=true` if it sends cookies. Setting
`RWS_CONFIG_CORS_ALLOW_ALL=true` restores the old echo-any-origin
behaviour; avoid it for anything with logins.
- **ACME renewal**: certificates renew at two-thirds of their lifetime (or
when the CA's renewal information says), not 30 days before expiry; set
`RWS_CONFIG_ACME_RENEW_BEFORE_DAYS=30` to keep the old margin. Code that
builds an `AcmeConfig` by hand sets `renew_before_days: None` (or
`Some(days)`) and `profile: None`.
- **`CacheLayer`** no longer stores `no-cache` responses, responses to `Range`
requests, or responses whose `Vary` names a request header outside its key.
Static files are therefore not cached by it (they were never meant to be).
## RSA public keys
`rws` verifies RS256 JWTs and SAML signatures itself now (the `rsa` crate had
an unfixed advisory, RUSTSEC-2023-0071). Two signatures change:
- `auth::verify_jwt_rs256(token, &key)` takes a `&rsa_key::RsaPublicKey`.
- `SamlIdpMetadata.signing_key` is an `rsa_key::RsaPublicKey`.
`JwtLayer::rs256(pem)` and the SSO/JWKS code are unchanged for callers.
```rust
use rust_web_server::rsa_key::RsaPublicKey;
let key = RsaPublicKey::from_public_key_pem(&pem)?; // BEGIN PUBLIC KEY or BEGIN RSA PUBLIC KEY
let key = RsaPublicKey::from_public_key_der(&der)?; // SPKI
let key = RsaPublicKey::from_components(&n_bytes, &e_bytes)?;
```
Keys under 2048 bits are rejected; a JWKS key that can't be used is skipped.
## `Request.body` is a `Body`
`Request.body` was a `Vec<u8>`; it is a `request::Body`, which can also be a
stream of a body still arriving. It dereferences to `&[u8]`, so reading
code mostly compiles unchanged. What to change:
| 17.x | 18.0 |
|---|---|
| `body: vec![]` | `body: Default::default()` |
| `body: bytes` (a `Vec<u8>`) | `body: bytes.into()` |
| `request.body.clone()` where a `Vec<u8>` is wanted | `request.body.to_vec()` |
| `String::from_utf8(request.body)` | `String::from_utf8(request.body.to_vec())` |
| `request.body = vec` | `request.body = vec.into()` |
| `assert_eq!(b"x".to_vec(), request.body)` | unchanged (`Vec<u8> == Body` works) |
## The `rws` binary runs one runtime per core
The `rws` binary now starts one single-threaded runtime per core, each
accepting on its own `SO_REUSEPORT` socket, like nginx's workers
(`RWS_CONFIG_RUNTIME=per-core`, Unix). On fast CPUs this serves more
requests per second; on slower ones it can cost more CPU per small request.
To keep 17.x behaviour, set `runtime = "shared"` in `rws.config.toml` or
`RWS_CONFIG_RUNTIME=shared`. Applications built on the library are
unchanged: they stay `shared` unless they set it.
## Coming from 17.113.0 or earlier
18.0 carries the 17.113.1 security fixes and the 17.113.2 conformance fixes.
If you skipped those releases, these refuse or change what you may rely on:
- **Static files:** paths with `..`, dot-files (`.git/`, `.env`; set
`RWS_CONFIG_SERVE_DOTFILES=true` to serve them), `*.pem`, `*.key` and
`rws.config.toml` are not served. Request paths are percent-decoded, so a
file whose name contains a literal `%` is requested as `%25`.
- **ACME** keeps its keys in `.rws/acme/` by default (files at the old
default paths are moved there on start).
- **The `rws` binary** mounts `/mcp` only when `MCP_TOKEN` is set.
- **HTTP/1.1 requests** without `Host` or with two, with a lower-case method,
a folded header line or whitespace before a header's colon get `400`; an
HTTP/1.0 connection closes after each response unless the client sends
`Connection: keep-alive`.
- **CORS** origins match whole: list each origin, a part of one no longer
matches.
## Also new in 18.0 (not breaking)
- A request head that doesn't complete within 30 s of its first byte gets
`408 Request Timeout`.
- `[[upstream]] tls_ca_file` trusts a private CA for `https://` upstreams.
- Inline tables in settings: `rate_limit = { max_requests = 100 }`.
The full list is in [`CHANGELOG.md`](https://github.com/bohdaq/rust-web-server/blob/main/CHANGELOG.md).
---
# Architecture
<https://rws8.tech/reference/architecture/>
`rws` is one crate: the server, the reverse proxy and the application
framework share one request type and one handler trait. HTTP/1.1 parsing,
routing, middleware, JSON, WebSocket, SSE and the HTTP/2 server are written
in-crate (the `h2` crate is only the proxies' HTTP/2 client); HTTP/3 uses
`h3` + `quinn`, TLS `rustls`.
## Builds
| Build | Runtime | Listeners |
|-------|---------|-----------|
| `http3` (default) | tokio | HTTP/1.1 and HTTP/2 over TLS (ALPN), HTTP/3 over QUIC on the same port, optional HTTP → HTTPS redirect |
| `http2` | tokio | as above without QUIC |
| `http1` | none — a thread pool | plain HTTP/1.1 |
Without a certificate the async builds serve plain HTTP/1.1 on the same port.
## A request's path
```
TcpListener / QUIC endpoint
└─ connection task (async builds) or pool thread (http1)
├─ TLS handshake, ALPN → HTTP/1.1 | HTTP/2 (10 s deadline)
├─ read head (≤ 64 KiB, 30 s from its first byte), then the body
│ (Content-Length or chunked; Expect: 100-continue; body limit → 413)
├─ Request::parse → Request + ConnectionInfo (addresses, SNI)
├─ Application::execute(&request, &connection) → Response
│ middleware layers → router / controllers / proxy
├─ gzip if the client accepts it and it pays off
└─ write head + body (one write, writev, sendfile, or chunked stream)
```
- **HTTP/1.1** connections are keep-alive loops with pipelining; one handler
serves plain TCP and TLS. Every read has a 30 s idle timeout.
- **HTTP/2 and HTTP/3** translate each stream into the same `Request`, call the
same `Application`, and translate the `Response` back into frames. Large
bodies stream in 64 KiB chunks within flow-control limits.
- **Where handlers run.** Handlers are synchronous. In the async builds a
request runs on tokio's blocking pool, unless the application says it is
cheap (`Application::runs_inline` — `App` does for GET and HEAD), in which
case it runs on the connection task. Async handlers
(`App::with_async_state`) and config-file proxy routes without middleware
are awaited without tying up a thread.
## Applications and middleware
Everything that answers requests implements one trait:
```rust
pub trait Application {
fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result;
}
```
- `App` — the built-in controllers (health, metrics, static files, …).
- `App::with_state(S)` / `App::with_async_state(S)` — your routes plus shared state.
- `Router` — path routing with `:params` and `*wildcards`, usable inside any application.
- `McpServer`, `ConfigDrivenApp` (the config-file proxy) — also applications.
Middleware wraps an application (`app.wrap(layer)`); the first layer added is
the outermost. Rate limiting, auth, CORS, caching, metrics, tracing, rewriting
and the reverse proxy itself are all `Middleware`, so one model covers a web
app, a gateway and a mix of both.
## Static files
A GET for a regular file takes a fast path: the open file handle, its size
and modification time, small files' contents (≤ 64 KiB) and their gzip
output are cached per file version, so a hit costs one `fstat`. An edit in
place is served at once (strict mode, the default). Files over 256 KiB
stream from disk; on plain TCP, downloads use `sendfile` with the response
head in the same TCP segment. Responses carry `ETag` and `Last-Modified` and
answer `304` to revalidation.
## Reverse proxy
Upstream HTTP/1.1 connections are pooled per backend (64 idle, 60 s). A
config-file route without middleware is forwarded entirely on the event loop;
others go through the blocking path with the same pool. Responses over 1 MB,
chunked or SSE are streamed through instead of buffered. Health checks,
load-balancing strategies, retries and circuit breakers sit around the pool.
See [Reverse proxy](/proxy/reverse-proxy/) and [Config-driven proxy](/proxy/config-driven/).
## Configuration
Settings are layered — built-in defaults, environment variables,
`rws.config.toml`, command-line flags — and all end up as `RWS_CONFIG_*`
environment variables at startup. Request handling reads a typed snapshot
(`ServerConfig::current()`), rebuilt when the configuration changes; `SIGHUP`
reloads CORS, rate limits, log format, proxy routes and TLS certificates
without a restart. See [Configuration](/configuration/overview/).
---
# Benchmarks
<https://rws8.tech/reference/benchmarks/>
`rws` is measured against Caddy and nginx on the same machine, with the same
files and the same load. The numbers below come from one run (40243377254) of the
[Benchmark workflow](https://github.com/bohdaq/rust-web-server/blob/main/.github/workflows/bench.yml)
on a 4-CPU GitHub Actions runner (Linux x86_64), with the load generator on the
same machine.
**Load:** [`oha`](https://github.com/hatoo/oha), 64 keep-alive connections,
20 seconds per scenario after a 2-second warm-up, HTTP/1.1, no compression
(`--disable-compression`: the Caddy and nginx configs don't compress, and
`rws` compresses text by default), access logs off everywhere. `rws` is the
release build (`--features "acme auth mimalloc"`), Caddy 2.11.7, nginx from Ubuntu with
`sendfile`, `open_file_cache` and upstream keep-alive enabled. nginx's
`open_file_cache_valid 1s` trusts a cached file's size and modification time
for a second; `rws` runs with the equivalent `open_file_cache_strict = false`
(its default re-checks them with an `fstat` on every request, so an edit in
place is served at once).
| Scenario | Server | Requests/s | p50 (ms) | p99 (ms) | CPU/request (µs) | Peak memory (MiB) |
|---|---|---:|---:|---:|---:|---:|
| Static file, 1 KiB | rws | 96,670 | 0.61 | 1.65 | 16.9 | 33.5 |
| | Caddy | 18,592 | 2.73 | 13.49 | 154.4 | 66.1 |
| | nginx | 81,161 | 0.59 | 3.10 | 25.1 | 23.2 |
| Static file, 1 MiB | rws | 9,511 | 6.26 | 14.00 | 209.8 | 33.5 |
| | Caddy | 2,398 | 19.03 | 124.03 | 1,194.7 | 69.4 |
| | nginx | 9,767 | 6.42 | 14.05 | 216.2 | 23.4 |
| Reverse proxy, 1 KiB | rws | 34,260 | 1.79 | 3.89 | 44.6 | 35.5 |
| | Caddy | 13,402 | 4.16 | 14.17 | 190.6 | 66.7 |
| | nginx | 33,942 | 1.74 | 4.51 | 47.1 | 24.2 |
CPU/request is the server's user + system CPU time over the run (including
worker processes) divided by the requests it served.
Every request in every scenario returned `200 OK`.
### Extended scenarios
The same run, with the extended scenarios (`EXTENDED=1`, see
[Reproduce](#reproduce)):
| Scenario | Server | Requests/s | p50 (ms) | p99 (ms) | CPU/request (µs) | Peak memory (MiB) |
|---|---|---:|---:|---:|---:|---:|
| 26 KiB JS file, gzip | rws | 86,327 | 0.69 | 1.87 | 20.9 | 41.8 |
| | Caddy | 5,729 | 8.51 | 51.96 | 606.3 | 109.2 |
| | nginx | 1,802 | 35.26 | 71.94 | 2,063.6 | 27.3 |
| Static 1 KiB, access log on | rws | 88,643 | 0.66 | 1.81 | 20.2 | 41.6 |
| | Caddy | 15,964 | 2.97 | 18.12 | 191.7 | 65.6 |
| | nginx | 75,518 | 0.62 | 3.16 | 28.6 | 23.2 |
| Static 1 KiB, HTTPS | rws | 70,372 | 0.82 | 2.22 | 25.9 | 49.4 |
| | Caddy | 25,555 | 2.22 | 9.57 | 112.6 | 69.4 |
| | nginx | 63,752 | 0.75 | 3.75 | 33.5 | 41.7 |
| Static 1 KiB, HTTP/2 | rws | 66,141 | 0.90 | 2.44 | 27.4 | 63.7 |
| | Caddy | 13,604 | 4.04 | 14.20 | 216.0 | 66.9 |
| | nginx | 51,416 | 0.84 | 4.46 | 37.9 | 42.2 |
| Static 1 MiB, HTTPS | rws | 1,602 | 35.23 | 49.34 | 923.7 | 65.7 |
| | Caddy | 1,460 | 38.13 | 108.27 | 1,483.6 | 68.0 |
| | nginx | 1,311 | 45.19 | 101.34 | 1,639.9 | 42.3 |
`rws` compresses a static text file once per version and serves the cached
result; nginx and Caddy compress on every request, which is why the gzip row
differs by two orders of magnitude (level 6 everywhere). HTTP/2 is served by
`rws`'s own implementation (since 18.0.0): with the `h2` crate, framing and
HPACK cost more per request than all of nginx's own code, and nginx led this
scenario by 23–41%. Large files over TLS were slow on AMD
EPYC 7763 runners until 18.0.0: rustls copies each record's plaintext into
a buffer 5 bytes past a page boundary, and with the file buffer page-aligned
too, glibc's `rep movsb` ran that copy at 2.8 instead of 40 GB/s (4 KiB
aliasing). The file buffer now starts mid-page; that scenario went from
nginx +9% to `rws` −35 to −50%.
On a 10-core Mac (same tools and configuration, `oha` on the same machine):
1 KiB static — rws 107k, nginx 100k; 1 MiB static — 9.8k vs 9.4k; proxy —
49.0k vs 48.9k requests per second.
## Reading the numbers
- **Against Caddy:** `rws` serves 4.0× (large) to 5.2× (small) as many file
requests, 2.6× as many proxied and 4.9× as many HTTP/2 requests per
second, using a quarter to a ninth of the CPU per request, with a p99
three to nine times lower and about half the memory.
- **Against nginx:** less CPU per request in every scenario — small files
−33%, large files −3%, proxying −5%, HTTP/2 −28%, HTTPS −23%, large files
over HTTPS −44%, with an access log −29%, with gzip a hundredth — more
requests per second in all but large plain files (−3%), and the lower p99
everywhere. This run used an AMD EPYC runner. On the fastest (Intel)
runner type nginx keeps 6–8% more plain-HTTP requests per second and
about the same CPU for large files and proxying (run 40209996636);
`RWS_CONFIG_RUNTIME=per-core` narrows that (run 40242045036). The load
generator shares the runner's 4 CPUs, so the CPU/request column is the
comparison to read.
- **Memory:** release builds allocate with mimalloc, which costs about 5%
less CPU per request than the system allocator but holds more memory —
34–66 MiB here, against nginx's 23–42. Built without the `mimalloc`
feature, `rws` peaks at 9–17 MiB.
- The gap depends on the runner's hardware; same-run A/Bs (`base_ref`,
below) are how changes are judged.
- Shared CI runners are noisy: absolute numbers vary by a factor of two
between runs. Compare servers within one run, not numbers across runs.
- Compression matters: with `Accept-Encoding: gzip` sent and only `rws`
compressing, the comparison measures `gzip`, not the servers. Compare like
for like.
## Why it's fast
- **Open files are reused** (an open-file cache like nginx's): a request for
a hot file costs one `fstat`; files up to 64 KiB are served from memory,
and the response head and body go out in one write.
- **Quick requests run on the event loop.** Static files, health checks and
metrics skip the hand-off to a blocking thread pool, as in nginx. Handlers
that may block (your routes) still get a pool thread, so a slow handler
can't stall other connections. See
[`runs_inline`](/building-apps/middleware/#running-on-the-event-loop-runs_inline).
- **Proxying holds no thread.** Proxy routes without middleware, to HTTP or
HTTPS backends, wait for the backend on the connection's task, over pooled
keep-alive connections.
- **One idle timer per connection**, pushed back on each read without a lock.
- **`sendfile` for downloads** over plain HTTP on Linux and macOS: the kernel
copies file pages straight to the socket, with the response head in the
same segment (`TCP_CORK` on Linux, `sendfile`'s header vector on macOS).
- **Configuration is a cached snapshot**, not read from the environment on
every request; a reload replaces it.
- **Few allocations per request.** The standard response headers are
borrowed `&'static str`s (`Header` fields are `Cow<'static, str>`), so
attaching them copies nothing; request heads and upstream response heads
are parsed in one pass over the bytes; numbers are formatted on the stack;
the file check is one `fstat`.
## Reproduce
Run the workflow from the repository's Actions tab ("Benchmark" → "Run
workflow"), or locally:
```bash
cargo build --release --bin rws --features "acme auth"
RWS=target/release/rws OHA=oha CADDY=caddy NGINX=nginx bench/run.sh
```
`CADDY` and `NGINX` are optional; the proxy scenario needs `NGINX`, which also
serves as the shared backend for every proxy under test. `DURATION` (default
`15s`) and `CONNECTIONS` (default `64`) tune the load. The script prints a
Markdown table and writes `bench-results.md` and `bench-results.json`.
`EXTENDED=1` (on by default in the workflow) adds the paths real deployments
hit that the three scenarios above leave out, each with equivalent settings
on every server: `text-gzip` (a 26 KiB JavaScript file with
`Accept-Encoding: gzip`, gzip level 6 everywhere), `log-1k` (an access log
written to a file), `https-1k` and `h2-1k` (TLS over HTTP/1.1 and HTTP/2),
and `https-1m` (a 1 MiB file over TLS, where `sendfile` doesn't apply). The
script makes a self-signed certificate with `openssl`, and skips a TLS
scenario for an nginx built without its ssl or http2 module.
## Profile
To see where `rws` spends its CPU, run the workflow with **profile** checked:
it builds with frame pointers and samples `rws` with `perf` during each
scenario, and nginx's workers too (`nginx-<scenario>` reports; the distro
nginx has no symbols, but its kernel time is comparable with `rws`'s). The job summary lists the hottest symbols, and the `bench-profile`
artifact has flat and call-tree reports plus `perf script` output for flame
graphs (`inferno-collapse-perf | inferno-flamegraph`). Locally, on Linux:
```bash
RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release --bin rws --features "acme auth"
PERF_DIR=perf RWS=target/release/rws OHA=oha NGINX=nginx bench/run.sh
perf report -i perf/static-1k.data --no-children
```
Sampling slows `rws` down, so take throughput numbers from an unprofiled run.
## Compare two builds
Runner hardware differs from run to run (nginx itself varies threefold), so
a change can only be judged against its baseline in the same run. Give the
workflow a **base_ref** (a branch, tag or commit) and it also builds that
ref and benches it as `rws-base`, right after `rws`; locally, set
`RWS_BASE=<binary>`. **base_workers** (`RWS_BASE_WORKERS`) runs `rws-base`
with that many tokio workers — with no base ref, the same build again — to
compare worker counts, and **base_runtime** (`RWS_BASE_RUNTIME`) does the same
for `RWS_CONFIG_RUNTIME` (shared or per-core). (Fewer workers than CPUs raises the measured
requests per second only because the load generator shares the runner's
CPUs; on a dedicated server one worker per core is right.)
---
# Roadmap
<https://rws8.tech/reference/roadmap/>
These features are planned. Each appears as a callout on the relevant docs page.
:::caution[Coming Soon]
**Baggage and automatic child spans**
W3C baggage propagation, and spans created automatically for each database query and outbound call. Child spans already work when started by hand with `otel::span` / `otel::client_span` — see [Distributed Tracing](/features/tracing/#child-spans).
:::
:::caution[Coming Soon]
**Admin UI**
`GET /admin` — a browser-based dashboard showing live metrics, current configuration, and a tail of the access log. Requires authentication.
:::
:::caution[Coming Soon]
**Access log rotation**
Built-in log-file rotation (by size or time) so the server can write logs to disk without an external `logrotate` configuration.
:::
---
**WebAssembly guest component** — shipped. `App`/`Router`/middleware now run inside a `wasi:http/proxy` WASM component (Wasmtime, Spin, Fastly Compute) via the separate `rws-wasm-shim` package. See [WASM Guest Component](/deployment/wasm/). Larger follow-up phases (streaming bodies, outbound HTTP from the guest) remain open — tracked in `spec/WASM_SHIM.md` in the repository.
---
The full ordered list of open work is [`spec/ROADMAP.md`](https://github.com/bohdaq/rust-web-server/blob/main/spec/ROADMAP.md); track progress and open issues at [github.com/bohdaq/rust-web-server](https://github.com/bohdaq/rust-web-server/issues).