Rapina 0.2.0 shipped on January 24, 2026. This was the first stable release and introduced authentication and configuration as core primitives.
Authentication
Authentication in 0.2.0 was built around a single design decision: protected by default. Every route required a valid JWT token unless explicitly opted out. No middleware registration, no per-router configuration — the protection was on unless you turned it off.
Setup
AuthConfig loaded from environment variables. The only required value was JWT_SECRET:
JWT_SECRET=your-secret-key
JWT_EXPIRATION=3600 # optional, defaults to 3600 secondsWiring it up:
use rapina::prelude::*;
use rapina::auth::AuthConfig;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let auth_config = AuthConfig::from_env().expect("Missing JWT_SECRET");
Rapina::new()
.with_auth(auth_config)
.router(
Router::new()
.post("/login", login)
.get("/me", me),
)
.listen("127.0.0.1:3000")
.await
}Calling .with_auth() enabled the auth middleware globally. Every route registered after that point required a valid Authorization: Bearer <token> header.
#[public]
To opt a route out of authentication, the #[public] attribute was added. It was designed to be placed alongside the route macro:
#[public]
#[get("/health")]
async fn health() -> &'static str {
"ok"
}
#[public]
#[post("/login")]
async fn login(auth: State<AuthConfig>) -> Result<Json<TokenResponse>> {
let token = auth.create_token("user123")?;
Ok(Json(TokenResponse::new(token, auth.expiration())))
}Internal routes under /__rapina were always public, regardless of auth configuration.
CurrentUser
Protected routes accessed the authenticated user through the CurrentUser extractor. It was populated automatically by the auth middleware — no setup needed in the handler:
use rapina::auth::CurrentUser;
#[get("/me")]
async fn me(user: CurrentUser) -> Json<serde_json::Value> {
Json(serde_json::json!({
"id": user.id,
}))
}If the token was missing or invalid, Rapina returned a 401 before the handler ran.
TokenResponse
TokenResponse was a standard response struct for login endpoints. It included the token and the expiration duration:
#[public]
#[post("/login")]
async fn login(auth: State<AuthConfig>) -> Result<Json<TokenResponse>> {
// In a real app, validate credentials first
let token = auth.create_token("user123")?;
Ok(Json(TokenResponse::new(token, auth.expiration())))
}Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600
}Configuration
The config system introduced a derive macro that eliminated the boilerplate of loading environment variables one by one.
#[derive(Config)]
Annotating a struct with #[derive(Config)] generated a from_env() method that loaded all fields from environment variables:
use rapina::prelude::*;
#[derive(Config)]
struct AppConfig {
#[env = "DATABASE_URL"]
database_url: String,
#[env = "PORT"]
#[default = "3000"]
port: u16,
#[env = "MAX_CONNECTIONS"]
#[default = "10"]
max_connections: u32,
}Loading it at startup:
#[tokio::main]
async fn main() -> std::io::Result<()> {
let config = AppConfig::from_env().expect("Invalid configuration");
Rapina::new()
.listen(format!("127.0.0.1:{}", config.port))
.await
}#[env = "VAR_NAME"]
The #[env] attribute mapped a struct field to a specific environment variable. Without it, the field name was uppercased and used directly:
#[derive(Config)]
struct AppConfig {
// reads DATABASE_URL (explicit)
#[env = "DATABASE_URL"]
database_url: String,
// reads SECRET (inferred from field name)
secret: String,
}Fail-fast validation
from_env() collected all missing variables before returning an error. If multiple required variables were absent, the error listed all of them at once:
Missing required environment variables:
- DATABASE_URL
- SECRETThis avoided the iteration of fixing one missing variable, restarting, and discovering the next one.
CLI
Two new commands were added to the Rapina CLI.
rapina routes
Lists all registered routes from the running application:
→ Fetching routes on http://127.0.0.1:3000...
METHOD PATH HANDLER
────── ──────────────────── ───────────────
GET / hello
POST /login login
GET /me me
GET /__rapina/health health_handler
✓ 4 route(s) registeredThe command connected to the running server and read the route registry directly. No static analysis, no build step.
rapina doctor
Ran a set of health checks against the running API:
→ Running API health checks on http://127.0.0.1:3000...
✓ All routes have response schemas
✓ All routes have documented errors
✓ No duplicate handler paths
⚠ Missing documentation: GET /me
Summary: 3 passed, 1 warnings, 0 errors
Consider addressing the warnings above.The checks included: missing response schemas, undocumented error responses, duplicate route registrations, and missing OpenAPI descriptions.
Docs site
The full documentation site launched at userapina.com alongside this release. It covered installation, routing, authentication, configuration, and the CLI.
Upgrade by bumping the version in your Cargo.toml:
rapina = "0.2.0"