ESC
Type to search...

Rapina 0.10.0

Serde-based path extraction, RFC 7807 errors, snapshot testing, database seeding, and router performance

Rapina 0.10.0 shipped on March 16, 2026. This release overhauled path extraction, error responses, and routing performance, and added snapshot testing and database seeding as first-class CLI primitives.


Serde-based Path<T> extraction

Path<T> was rewritten around a custom serde deserializer. A single implementation now handled three shapes:

// Single parameter
#[get("/users/:id")]
async fn get_user(id: Path<u64>) -> Json<User> { ... }

// Tuple — parameters in definition order
#[get("/orgs/:org/repos/:repo")]
async fn get_repo(params: Path<(String, String)>) -> Json<Repo> { ... }

// Named struct — parameters by field name
#[derive(Deserialize)]
struct RepoPath {
    org: String,
    repo: String,
}

#[get("/orgs/:org/repos/:repo")]
async fn get_repo(params: Path<RepoPath>) -> Json<Repo> { ... }

Previously each shape required a separate sealed trait implementation. The new deserializer-based approach covered all three from a single impl<T: DeserializeOwned> FromRequestParts for Path<T>.


RFC 7807 Problem Details

Error responses gained opt-in support for RFC 7807 Problem Details format. Enabled via .enable_rfc7807_errors():

Rapina::new()
    .enable_rfc7807_errors()
    .router(router)
    .listen("127.0.0.1:3000")
    .await

With a custom base URI for the type field:

Rapina::new()
    .enable_rfc7807_errors()
    .rfc7807_base_uri("https://myapp.com/errors")
    .router(router)
    .listen("127.0.0.1:3000")
    .await

A NOT_FOUND error with that URI produced:

{
  "type": "https://myapp.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "User 42 not found",
  "trace_id": "..."
}

The response also included an optional instance field (omitted when None) for identifying the specific occurrence of the problem.

The ErrorConfig was scoped per-request: the middleware stack pulled it from AppState and wrapped each request's Next::run future in ERROR_CONFIG.scope(), so tests running in parallel each got their own isolated configuration.


Snapshot testing

response.assert_snapshot("name") captured a golden file on first run and compared against it on subsequent runs. Dynamic values were redacted automatically so snapshots stayed stable across runs:

  • UUIDs → [UUID]
  • ISO 8601 timestamps → [TIMESTAMP]
  • trace_id values → [UUID]
#[tokio::test]
async fn test_list_users() {
    let app = create_app();
    let client = TestClient::new(app);

    let response = client.get("/users").await;
    response.assert_snapshot("list_users");
}

Snapshots were stored as .snap files. To update golden files after an intentional change:

RAPINA_BLESS=1 cargo test

Or via the CLI flag:

rapina test --bless

Database seeding

Three new subcommands were added under rapina seed:

# Load seed data from JSON files into the database
rapina seed load

# Load a specific entity only
rapina seed load --entity users

# Wipe and reload from scratch
rapina seed load --fresh

# Dump current database contents to JSON seed files
rapina seed dump

# Generate fake seed data based on schema types (default: 10 records)
rapina seed generate --count 10

Router performance

The router gained two structural improvements to reduce allocation and lookup cost on hot paths.

Static route map

Static routes (no :param segments) were moved into a dedicated HashMap for O(1) lookup by (method, path) key. Previously all routes went through a linear scan.

Radix trie for dynamic routes

Dynamic routes were matched through a radix trie with O(path_depth) complexity, replacing the previous linear scan. Static children took precedence over param children at every trie node, so /users/current always won over /users/:id regardless of registration order.

Criterion benchmarks

Router benchmarks using Criterion were added to measure resolution performance across static and dynamic route configurations.


Configurable request logging

RequestLogConfig replaced the previous fixed-format RequestLogMiddleware. Verbosity and header redaction were configurable:

use rapina::middleware::RequestLogConfig;

// All fields with default redaction list
Rapina::new()
    .with_request_log(RequestLogConfig::verbose())
    .router(router)
    .listen("127.0.0.1:3000")
    .await

Or built with individual flags:

RequestLogConfig::default()
    .log_headers(true)
    .log_query_params(true)
    .log_body_size(true)
    .redact_header("x-internal-token")

RequestLogConfig::verbose() enabled all fields and automatically redacted authorization, proxy-authorization, cookie, set-cookie, and x-api-key.


State<T> wrapped in Arc<T>

State<T> extraction was changed from cloning the inner value to bumping an atomic reference count. This removed the Clone bound on state types and made extraction cheaper:

// Before — Clone required
#[derive(Clone)]
struct AppConfig {
    db_url: String,
}

// After — no Clone needed
struct AppConfig {
    db_url: String,
}

into_inner() now returns Arc<T> instead of T.


Positional extractor convention

The proc macro previously classified extractors by matching type name strings (e.g. checking for "Path", "Query", "State"), which misclassified user types like UserPathInfo or MyQueryBuilder.

The convention was replaced with an axum-style positional rule: all handler parameters except the last use FromRequestParts; the last parameter may use FromRequest (body-consuming). Body-consuming extractors (Json, Form, Validated) must now be the last parameter — the compiler enforces this via trait bounds.

Also part of this release, the serde-based Path<T> rewrite changed the macro to bind extracted values via let #pat = #tmp instead of let #ident = ..., which as a side-effect preserved the mut keyword when present on handler arguments. This enables mutable extractors in any position:

#[post("/upload")]
async fn upload(mut form: Multipart) -> Result<Json<UploadResult>> {
    while let Some(field) = form.next_field().await? {
        // ...
    }
}

PathParams backed by SmallVec

PathParams was changed from a HashMap<String, String> to a SmallVec-backed struct with inline capacity for 4 entries. Typical REST routes (1–3 path parameters) required no heap allocation. Lookup used a linear scan, which outperformed hashing for small N.


Schema macro additions

UUID primary keys

The schema! macro gained support for UUID primary keys. Combine #[primary_key(id)] with id: Uuid to generate a model with rapina::uuid::Uuid and auto_increment = false:

schema! {
    #[primary_key(id)]
    Post {
        id: Uuid,
        title: String,
        body: String,
    }
}

Option<T> for nullable columns

rapina import database now generated Option<T> for nullable columns. The database introspection layer read column nullability from the schema and set the field type accordingly. rapina add resource, which takes fields from user input rather than a live database, continued to generate non-optional types.


Other additions

#[patch] proc-macro and Router::patch()

PATCH routes were added alongside the existing GET, POST, PUT, and DELETE macros:

#[patch("/users/:id")]
async fn update_user(id: Path<u64>, body: Json<UpdateUser>) -> Result<Json<User>> {
    // partial update
}

Router::patch() and Router::patch_named() provided the equivalent manual registrations.

put_named and delete_named

Router::put_named() and Router::delete_named() complemented the existing get_named, post_named, and patch_named convenience methods for named route registration.

--force flag for import database

rapina import database gained a --force flag to re-import over existing generated files without prompting.

Irregular plurals in codegen

The singularize/pluralize functions used in rapina add resource and rapina import openapi were extended to handle irregular forms and uncountable words. Words like status, child, person, and leaf now produced correct singular and plural forms in generated model names, route paths, and handler identifiers.

Duplicate route detection in rapina doctor

rapina doctor gained a new check for duplicate (method, path) pairs. When two handlers were registered for the same route, only one would be used — the new check surfaced this as a warning before it caused silent bugs in production.

Compression now a feature flag

The compression feature was made explicit in Cargo.toml. It was enabled in the default feature set, so existing projects were unaffected. Projects that wanted to opt out could disable default features:

rapina = { version = "0.10", default-features = false, features = ["database"] }

URL shortener example

A full URL shortener example was added to the repository, demonstrating database integration, CRUD handlers, and migrations end-to-end.


Upgrade by bumping the version in your Cargo.toml:

rapina = "0.10"