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>
205 lines
6.4 KiB
Rust
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),
|
|
}
|
|
);
|
|
}
|
|
}
|