fix(api): enable non-strict mode for URL-encoded bracket notation

## Problem
Dashboard sends URL-encoded query parameters:
  ?sources%5B%5D=rfc&sources%5B%5D=owasp
  (%5B = '[', %5D = ']')

But QsQuery extractor used strict mode, which rejects encoded brackets:
  Error: "Invalid field contains an encoded bracket"

Result: All corpus filters in the dashboard failed silently.

## Solution
Changed QsQuery to use serde_qs non-strict mode:
  Config::new(5, false) // false = non-strict

Now accepts BOTH:
  - Literal brackets: ?sources[]=rfc
  - Encoded brackets: ?sources%5B%5D=rfc (browsers)

## Verification
 URL-encoded query: ?sources%5B%5D=rfc&sources%5B%5D=community
   Returns: 24 items (was: error)
   Logs: sources=Some(["rfc", "community"]) 

 Literal brackets: ?sources[]=rfc (still works)
 All 4 extractor tests pass (added encoded brackets test)
 Clippy clean (0 warnings)

## Files Changed
- crates/stemedb-api/src/extractors.rs: Use non-strict Config
- crates/stemedb-api/README.md: Document QsQuery usage
- .claude/guides/backend/api-endpoints.md: Add best practices
- CLAUDE.md: Reference extractors documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
jml 2026-02-09 16:11:25 +00:00
parent bb0c33f8d3
commit 4012791e7e
4 changed files with 177 additions and 5 deletions

View File

@ -76,6 +76,81 @@ Rules:
- Always return `Result<_, ApiError>` for consistent error responses
- Use `tag = "..."` to group related endpoints in docs
### 2.1. Query Parameters with Arrays
**CRITICAL:** If your query parameter DTO contains `Vec<T>` or `Option<Vec<T>>`, you MUST use `QsQuery` instead of standard `Query`.
#### Use `QsQuery` for Array Parameters ✅
```rust
use crate::extractors::QsQuery;
#[derive(Deserialize, ToSchema)]
pub struct FilterRequest {
/// List of sources to filter by
pub sources: Option<Vec<String>>, // ← Array type
pub limit: usize,
}
#[utoipa::path(
get,
path = "/my-endpoint",
params(
("sources" = Option<Vec<String>>, Query, description = "Filter sources"),
("limit" = usize, Query, description = "Max results"),
),
// ...
)]
pub async fn my_handler(
State(state): State<AppState>,
QsQuery(params): QsQuery<FilterRequest>, // ✅ CORRECT
) -> Result<Json<MyResponse>> {
// Dashboard sends: ?sources[]=rfc&sources[]=community&limit=10
// params.sources = Some(vec!["rfc", "community"])
}
```
#### Never Use Standard `Query` with Arrays ❌
```rust
// ❌ WRONG - Will silently fail!
pub async fn broken_handler(
State(state): State<AppState>,
Query(params): Query<FilterRequest>, // sources will always be None!
) -> Result<Json<MyResponse>> {
// Dashboard sends: ?sources[]=rfc
// Result: params.sources = None (filter doesn't work!)
}
```
#### Use Standard `Query` for Scalar Parameters Only ✅
```rust
use axum::extract::Query;
#[derive(Deserialize, ToSchema)]
pub struct SimpleRequest {
pub limit: usize,
pub offset: usize,
pub category: Option<String>, // All scalars, no arrays
}
pub async fn simple_handler(
State(state): State<AppState>,
Query(params): Query<SimpleRequest>, // ✅ CORRECT
) -> Result<Json<MyResponse>> {
// ?limit=10&offset=0&category=security
}
```
**Why:** The StemeDB Dashboard uses JavaScript's `URLSearchParams` which generates bracket notation for arrays (`?filters[]=a&filters[]=b`). Standard `axum::extract::Query` uses `serde_urlencoded` which doesn't support brackets. `QsQuery` uses `serde_qs` which does.
**Quick Check:**
- DTO has `Vec` or `Option<Vec>` field? → Use `QsQuery`
- All fields are scalars? → Use `Query` or `AxumQuery`
See `crates/stemedb-api/src/extractors.rs` for detailed documentation.
### 3. Register the Route
Add to the `OpenApiRouter` in `crates/stemedb-api/src/lib.rs`:
@ -118,6 +193,7 @@ Map internal errors to API errors in the handler layer. Never leak internal erro
- [ ] DTO types in `dto.rs` with `Serialize, Deserialize, ToSchema`
- [ ] `From<>` conversions between DTOs and internal types
- [ ] Handler annotated with `#[utoipa::path]`
- [ ] **Query parameters:** Use `QsQuery` if DTO has `Vec` fields, otherwise use `Query`
- [ ] Error responses documented in `responses(...)`
- [ ] Route registered on `OpenApiRouter`
- [ ] Tests cover happy path and error cases

View File

@ -208,6 +208,7 @@ const MAX_POOL_SIZE: u32 = 50;
- **Zero-Copy:** Use `rkyv` for serialization. ALWAYS use `stemedb_core::serde::{serialize, deserialize}` — NEVER use raw `AllocSerializer` in production code.
- **Instrument Critical Paths:** Use `#[instrument]` on public methods in WAL, storage, ingestion, and lens code. Include meaningful fields (key_len, payload_len, offset, candidates_count, lens).
- **Structured Logging:** Use `tracing` (info!, warn!, error!) instead of `println!`/`eprintln!`. Clippy enforces via `print_stdout`/`print_stderr` at warn level. CLI binaries (e.g., `stemedb-sim`) may use `#![allow()]` for user-facing output.
- **Query Parameter Arrays:** In API handlers, use `QsQuery` extractor (not standard `Query`) for any DTO with `Vec<T>` or `Option<Vec<T>>` fields. Dashboard uses bracket notation (`?sources[]=a&sources[]=b`) which requires `serde_qs`. Standard `Query` silently fails on array params. See `crates/stemedb-api/src/extractors.rs` for details.
- **Document Changes:** Update `ai-lookup/` when adding new types/concepts. Keep skills in sync with code.
- **No Git Operations:** NEVER use git stash, git branch, git checkout, or any git operations unless the user explicitly tells you to.
- **No GitHub Workflows:** We use pre-commit hooks, not GitHub Actions CI.

View File

@ -11,6 +11,71 @@ The API follows the standard axum pattern:
- **State** (`state.rs`) - Shared application state (Journal, Store)
- **Router** (`lib.rs`) - axum router with OpenAPI support via utoipa
## Query Parameter Patterns
### When to Use QsQuery vs Query
The API uses two different query parameter extractors depending on whether array parameters are needed:
#### Use `QsQuery` for Array Parameters
**Required when:** Your request DTO contains `Vec<T>` or `Option<Vec<T>>` fields.
```rust
use crate::extractors::QsQuery;
#[derive(Deserialize)]
struct MyRequest {
sources: Option<Vec<String>>, // Array parameter
limit: usize,
}
async fn my_handler(
State(state): State<AppState>,
QsQuery(params): QsQuery<MyRequest>, // ✅ Correct
) -> Result<Json<MyResponse>> {
// Dashboard sends: ?sources[]=rfc&sources[]=community&limit=10
// params.sources = Some(vec!["rfc", "community"])
}
```
**Why:** The StemeDB Dashboard uses JavaScript's `URLSearchParams` which generates bracket notation for arrays (`?filters[]=a&filters[]=b`). Standard `axum::extract::Query` uses `serde_urlencoded` which doesn't support bracket notation. `QsQuery` uses `serde_qs` which does.
**Warning:** If you use standard `Query` with array parameters, the dashboard filters will **silently fail** (returning all results instead of filtered results).
#### Use Standard `Query` for Scalar Parameters
**Required when:** All query parameters are scalars (no arrays/vectors).
```rust
use axum::extract::Query;
#[derive(Deserialize)]
struct SimpleRequest {
limit: usize,
offset: usize,
category: Option<String>,
}
async fn simple_handler(
State(state): State<AppState>,
Query(params): Query<SimpleRequest>, // ✅ Correct
) -> Result<Json<MyResponse>> {
// Standard URL: ?limit=10&offset=0&category=security
}
```
**When to use alias:** If your handler file also imports `stemedb_query::Query`, use `use axum::extract::Query as AxumQuery` to avoid name collision.
### Quick Reference
| DTO Field Types | Extractor | Example |
|-----------------|-----------|---------|
| All scalars (String, usize, Option<bool>) | `Query` or `AxumQuery` | `handlers/meter.rs:60` |
| Contains Vec or Option<Vec> | `QsQuery` | `handlers/aphoria/corpus.rs:41` |
See `src/extractors.rs` for detailed documentation and examples.
## Write Path
```

View File

@ -31,9 +31,9 @@ impl IntoResponse for QsQueryRejection {
/// Query string extractor that supports bracket notation (e.g., `?sources[]=value1&sources[]=value2`).
///
/// This extractor uses `serde_qs` instead of `serde_urlencoded` to properly handle
/// array parameters with bracket notation, which is the standard format used by
/// JavaScript's URLSearchParams and the StemeDB Dashboard.
/// This extractor uses `serde_qs` in **non-strict mode** to properly handle
/// array parameters with bracket notation (both literal `[]` and URL-encoded `%5B%5D`),
/// which is the standard format used by JavaScript's URLSearchParams and web browsers.
///
/// # When to Use QsQuery vs Query
///
@ -43,7 +43,7 @@ impl IntoResponse for QsQueryRejection {
/// - You need bracket notation support: `?filters[]=a&filters[]=b`
///
/// **Use standard `axum::extract::Query` when:**
/// - All query parameters are scalars (String, usize, bool, Option<String>, etc.)
/// - All query parameters are scalars (String, usize, bool, `Option<String>`, etc.)
/// - No array/vector parameters needed
/// - Simpler and lighter weight for non-array cases
///
@ -99,7 +99,11 @@ where
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or_default();
let value = serde_qs::from_str(query).map_err(|err| QsQueryRejection {
// Use non-strict mode to accept both encoded (%5B%5D) and literal ([]) brackets.
// Browsers URL-encode brackets, so sources[] becomes sources%5B%5D in the query string.
let config = serde_qs::Config::new(5, false);
let value = config.deserialize_str(query).map_err(|err| QsQueryRejection {
message: err.to_string(),
})?;
Ok(QsQuery(value))
@ -184,4 +188,30 @@ mod tests {
}
);
}
#[tokio::test]
async fn test_encoded_brackets() {
// Test URL-encoded brackets (%5B = '[', %5D = ']')
// This is what browsers send when using URLSearchParams
let uri: Uri =
"http://example.com?sources%5B%5D=rfc&sources%5B%5D=owasp&sources%5B%5D=community&limit=100"
.parse()
.unwrap();
let mut parts = Request::builder().uri(uri).body(()).unwrap().into_parts().0;
let QsQuery(params): QsQuery<TestParams> =
QsQuery::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(
params,
TestParams {
sources: Some(vec![
"rfc".to_string(),
"owasp".to_string(),
"community".to_string()
]),
limit: Some(100),
}
);
}
}