stemedb/crates/stemedb-api/src/extractors.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

- Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping)
- LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index
- EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation)
- CLI handlers: all claim commands read/write through StemeDB
- Scanner: loads claims from StemeDB with auto-migration fallback to TOML
- Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON

Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations,
fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes
pre-existing clippy warnings (too_many_arguments, filter_next→rfind).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 02:02:51 -07:00

205 lines
6.4 KiB
Rust

//! Custom axum extractors for the StemeDB API.
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
};
use serde::de::DeserializeOwned;
use std::fmt;
/// Rejection type for QsQuery extraction failures.
#[derive(Debug)]
pub struct QsQueryRejection {
message: String,
}
impl fmt::Display for QsQueryRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Failed to deserialize query string: {}", self.message)
}
}
impl std::error::Error for QsQueryRejection {}
impl IntoResponse for QsQueryRejection {
fn into_response(self) -> Response {
(StatusCode::BAD_REQUEST, self.message).into_response()
}
}
/// Query string extractor that supports bracket notation (e.g., `?sources[]=value1&sources[]=value2`).
///
/// 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
///
/// **Use `QsQuery` when:**
/// - Your request DTO contains `Vec<T>` or `Option<Vec<T>>` fields
/// - The endpoint is called by the dashboard or JavaScript clients
/// - 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.)
/// - No array/vector parameters needed
/// - Simpler and lighter weight for non-array cases
///
/// # Example
///
/// ```rust,ignore
/// use stemedb_api::extractors::QsQuery;
/// use serde::Deserialize;
///
/// #[derive(Deserialize)]
/// struct MyRequest {
/// sources: Option<Vec<String>>, // Array parameter
/// limit: usize, // Scalar parameter
/// }
///
/// // ✅ Correct - QsQuery handles both array and scalar params
/// async fn handler(QsQuery(params): QsQuery<MyRequest>) {
/// // Dashboard sends: ?sources[]=rfc&sources[]=community&limit=10
/// // params.sources = Some(vec!["rfc", "community"])
/// // params.limit = 10
/// }
///
/// // ❌ Wrong - standard Query can't parse bracket notation
/// async fn wrong_handler(Query(params): Query<MyRequest>) {
/// // Dashboard sends: ?sources[]=rfc&sources[]=community
/// // Result: params.sources = None (silently fails!)
/// }
/// ```
///
/// # Dashboard Compatibility
///
/// The StemeDB Dashboard uses JavaScript's `URLSearchParams.append()` which generates
/// bracket notation for arrays:
///
/// ```javascript
/// // Dashboard code
/// params.sources.forEach(s => searchParams.append("sources[]", s));
/// // Generates: ?sources[]=rfc&sources[]=owasp&sources[]=community
/// ```
///
/// If you use standard `Query` for array parameters, the dashboard filters will appear
/// to work but silently fail (returning all results instead of filtered results).
#[derive(Debug, Clone, Copy, Default)]
pub struct QsQuery<T>(pub T);
#[async_trait]
impl<T, S> FromRequestParts<S> for QsQuery<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = QsQueryRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let query = parts.uri.query().unwrap_or_default();
// 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))
}
}
impl<T> std::ops::Deref for QsQuery<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> std::ops::DerefMut for QsQuery<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{Request, Uri};
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct TestParams {
sources: Option<Vec<String>>,
limit: Option<usize>,
}
#[tokio::test]
async fn test_bracket_notation() {
let uri: Uri =
"http://example.com?sources[]=rfc&sources[]=community&limit=10".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(), "community".to_string()]),
limit: Some(10),
}
);
}
#[tokio::test]
async fn test_no_brackets() {
let uri: Uri = "http://example.com?limit=5".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: None, limit: Some(5) });
}
#[tokio::test]
async fn test_empty_query() {
let uri: Uri = "http://example.com".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: None, limit: None });
}
#[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),
}
);
}
}