ESC
Type to search...

Rapina 0.2.0

The first stable release — JWT authentication, type-safe config, and new CLI tools

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 seconds

Wiring 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
  - SECRET

This 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) registered

The 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"