Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions crates/rustapi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ proptest = "1.4"
rustapi-testing = { workspace = true }
reqwest = { version = "0.12", features = ["json", "stream"] }
async-stream = "0.3"
async-trait = { workspace = true }
[features]
default = ["swagger-ui", "tracing"]
swagger-ui = ["rustapi-openapi/swagger-ui"]
Expand Down
182 changes: 180 additions & 2 deletions crates/rustapi-core/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,12 @@ impl<T: DeserializeOwned + AsyncValidate + Send + Sync> FromRequest for AsyncVal
let value: T = json::from_slice(&body)?;

// Create validation context from request
// TODO: Extract validators from App State
let ctx = ValidationContext::default();
// Check if validators are configured in App State
let ctx = if let Some(ctx) = req.state().get::<ValidationContext>() {
ctx.clone()
} else {
ValidationContext::default()
};
Comment on lines +382 to +386
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern for extracting state values in this codebase uses .cloned() on the Option rather than .clone() on the inner value. This should be:

let ctx = req.state().get::<ValidationContext>().cloned()
    .unwrap_or_default();

This is more idiomatic and consistent with how other extractors like State<T> (line 564) and Extension<T> (line 800) handle state extraction.

Copilot uses AI. Check for mistakes.

// Perform full validation (sync + async)
if let Err(errors) = value.validate_full(&ctx).await {
Expand Down Expand Up @@ -1715,4 +1719,178 @@ mod tests {
assert_eq!(cookies.get("token").unwrap().value(), "xyz789");
}
}

#[tokio::test]
async fn test_async_validated_json_with_state_context() {
use async_trait::async_trait;
use rustapi_validate::prelude::*;
use rustapi_validate::v2::{
AsyncValidationRule, DatabaseValidator, ValidationContextBuilder,
};
use serde::{Deserialize, Serialize};

struct MockDbValidator {
unique_values: Vec<String>,
}

#[async_trait]
impl DatabaseValidator for MockDbValidator {
async fn exists(
&self,
_table: &str,
_column: &str,
_value: &str,
) -> Result<bool, String> {
Ok(true)
}
async fn is_unique(
&self,
_table: &str,
_column: &str,
value: &str,
) -> Result<bool, String> {
Ok(!self.unique_values.contains(&value.to_string()))
}
async fn is_unique_except(
&self,
_table: &str,
_column: &str,
value: &str,
_except_id: &str,
) -> Result<bool, String> {
Ok(!self.unique_values.contains(&value.to_string()))
}
}

#[derive(Debug, Deserialize, Serialize)]
struct TestUser {
email: String,
}

impl Validate for TestUser {
fn validate_with_group(
&self,
_group: rustapi_validate::v2::ValidationGroup,
) -> Result<(), rustapi_validate::v2::ValidationErrors> {
Ok(())
}
}

#[async_trait]
impl AsyncValidate for TestUser {
async fn validate_async_with_group(
&self,
ctx: &ValidationContext,
_group: rustapi_validate::v2::ValidationGroup,
) -> Result<(), rustapi_validate::v2::ValidationErrors> {
let mut errors = rustapi_validate::v2::ValidationErrors::new();

let rule = AsyncUniqueRule::new("users", "email");
if let Err(e) = rule.validate_async(&self.email, ctx).await {
errors.add("email", e);
}

errors.into_result()
}
}

// Test 1: Without context in state (should fail due to missing validator)
let uri: http::Uri = "/test".parse().unwrap();
let user = TestUser {
email: "new@example.com".to_string(),
};
let body_bytes = serde_json::to_vec(&user).unwrap();

let builder = http::Request::builder()
.method(Method::POST)
.uri(uri.clone())
.header("content-type", "application/json");
let req = builder.body(()).unwrap();
let (parts, _) = req.into_parts();

// Construct Request with BodyVariant::Buffered
let mut request = Request::new(
parts,
crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())),
Arc::new(Extensions::new()),
PathParams::new(),
);

let result = AsyncValidatedJson::<TestUser>::from_request(&mut request).await;

assert!(result.is_err(), "Expected error when validator is missing");
let err = result.unwrap_err();
let err_str = format!("{:?}", err);
assert!(
err_str.contains("Database validator not configured")
|| err_str.contains("async_unique"),
"Error should mention missing configuration or rule: {:?}",
err_str
);

// Test 2: With context in state (should succeed)
let db_validator = MockDbValidator {
unique_values: vec!["taken@example.com".to_string()],
};
let ctx = ValidationContextBuilder::new()
.database(db_validator)
.build();

let mut extensions = Extensions::new();
extensions.insert(ctx);

let builder = http::Request::builder()
.method(Method::POST)
.uri(uri.clone())
.header("content-type", "application/json");
let req = builder.body(()).unwrap();
let (parts, _) = req.into_parts();

let mut request = Request::new(
parts,
crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())),
Arc::new(extensions),
PathParams::new(),
);

let result = AsyncValidatedJson::<TestUser>::from_request(&mut request).await;
assert!(
result.is_ok(),
"Expected success when validator is present and value is unique. Error: {:?}",
result.err()
);

// Test 3: With context in state (should fail validation logic)
let user_taken = TestUser {
email: "taken@example.com".to_string(),
};
let body_taken = serde_json::to_vec(&user_taken).unwrap();

let db_validator = MockDbValidator {
unique_values: vec!["taken@example.com".to_string()],
};
let ctx = ValidationContextBuilder::new()
.database(db_validator)
.build();

let mut extensions = Extensions::new();
extensions.insert(ctx);

let builder = http::Request::builder()
.method(Method::POST)
.uri("/test")
.header("content-type", "application/json");
let req = builder.body(()).unwrap();
let (parts, _) = req.into_parts();

let mut request = Request::new(
parts,
crate::request::BodyVariant::Buffered(Bytes::from(body_taken)),
Arc::new(extensions),
PathParams::new(),
);

let result = AsyncValidatedJson::<TestUser>::from_request(&mut request).await;
assert!(result.is_err(), "Expected validation error for taken email");
}
}
4 changes: 2 additions & 2 deletions crates/rustapi-validate/src/v2/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub trait CustomValidator: Send + Sync {
///
/// user.validate_async(&ctx).await?;
/// ```
#[derive(Default)]
#[derive(Clone, Default)]
pub struct ValidationContext {
database: Option<Arc<dyn DatabaseValidator>>,
http: Option<Arc<dyn HttpValidator>>,
Expand Down Expand Up @@ -114,7 +114,7 @@ impl std::fmt::Debug for ValidationContext {
}

/// Builder for constructing a `ValidationContext`.
#[derive(Default)]
#[derive(Clone, Default)]
pub struct ValidationContextBuilder {
database: Option<Arc<dyn DatabaseValidator>>,
http: Option<Arc<dyn HttpValidator>>,
Expand Down
Loading