Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions docs/content/blog/2026-03-16-rapina-0-10-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
+++
title = "Rapina 0.10.0"
description = "Serde-based path extraction, RFC 7807 errors, snapshot testing, database seeding, and router performance"
date = 2026-03-16

[taxonomies]
categories = ["release-notes"]
tags = ["release", "router", "extractors", "testing", "errors", "seeding"]

[extra]
author = "uemuradevexe"
+++

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:

```rust
// 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](https://datatracker.ietf.org/doc/html/rfc7807) Problem Details format. Enabled via `.enable_rfc7807_errors()`:

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

With a custom base URI for the `type` field:

```rust
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:

```json
{
"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]`

```rust
#[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:

```bash
RAPINA_BLESS=1 cargo test
```

Or via the CLI flag:

```bash
rapina test --bless
```

---

## Database seeding

Three new subcommands were added under `rapina seed`:

```bash
# 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](https://github.com/bheisler/criterion.rs) 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:

```rust
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:

```rust
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:

```rust
// 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:

```rust
#[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`:

```rust
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:

```rust
#[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:

```toml
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`:

```toml
rapina = "0.10"
```
Loading