Skip to content
Merged
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
33 changes: 19 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 12 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ members = [
]

[workspace.package]
version = "0.1.15"
version = "0.1.191"
edition = "2021"
authors = ["RustAPI Contributors"]
license = "MIT OR Apache-2.0"
Expand Down Expand Up @@ -100,16 +100,16 @@ indicatif = "0.17"
console = "0.15"

# Internal crates
rustapi-core = { path = "crates/rustapi-core", version = "0.1.15", default-features = false }
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.15" }
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.15" }
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.15", default-features = false }
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.15" }
rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.15" }
rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.15" }
rustapi-view = { path = "crates/rustapi-view", version = "0.1.15" }
rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.15" }
rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.15" }
rustapi-core = { path = "crates/rustapi-core", version = "0.1.188", default-features = false }
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.188" }
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.188" }
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.188", default-features = false }
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.188" }
rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.188" }
rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.188" }
rustapi-view = { path = "crates/rustapi-view", version = "0.1.188" }
rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.188" }
rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.188" }

# HTTP/3 (QUIC)
quinn = "0.11"
Expand All @@ -118,3 +118,4 @@ h3-quinn = "0.0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
rustls-pemfile = "2.2"
rcgen = "0.13"

3 changes: 3 additions & 0 deletions crates/rustapi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ brotli = { version = "6.0", optional = true }
cookie = { version = "0.18", optional = true }

# Validation
validator = { workspace = true }
rustapi-validate = { workspace = true }

# Metrics (optional)
Expand Down Expand Up @@ -95,3 +96,5 @@ simd-json = ["dep:simd-json"]
tracing = []
http3 = ["dep:quinn", "dep:h3", "dep:h3-quinn", "dep:rustls", "dep:rustls-pemfile"]
http3-dev = ["http3", "dep:rcgen"]


115 changes: 109 additions & 6 deletions crates/rustapi-core/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ use crate::json;
use crate::request::Request;
use crate::response::IntoResponse;
use crate::stream::{StreamingBody, StreamingConfig};
use crate::validation::Validatable;
use bytes::Bytes;
use http::{header, StatusCode};
use rustapi_validate::v2::{AsyncValidate, ValidationContext};

use serde::de::DeserializeOwned;
use serde::Serialize;
Expand Down Expand Up @@ -253,7 +255,7 @@ impl<T> ValidatedJson<T> {
}
}

impl<T: DeserializeOwned + rustapi_validate::Validate + Send> FromRequest for ValidatedJson<T> {
impl<T: DeserializeOwned + Validatable + Send> FromRequest for ValidatedJson<T> {
async fn from_request(req: &mut Request) -> Result<Self> {
req.load_body().await?;
// First, deserialize the JSON body using simd-json when available
Expand All @@ -263,11 +265,8 @@ impl<T: DeserializeOwned + rustapi_validate::Validate + Send> FromRequest for Va

let value: T = json::from_slice(&body)?;

// Then, validate it
if let Err(validation_error) = rustapi_validate::Validate::validate(&value) {
// Convert validation error to API error with 422 status
return Err(validation_error.into());
}
// Then, validate it using the unified Validatable trait
value.do_validate()?;

Ok(ValidatedJson(value))
}
Expand Down Expand Up @@ -299,6 +298,110 @@ impl<T: Serialize> IntoResponse for ValidatedJson<T> {
}
}

/// Async validated JSON body extractor
///
/// Parses the request body as JSON, deserializes into type `T`, and validates
/// using the `AsyncValidate` trait from `rustapi-validate`.
///
/// This extractor supports async validation rules, such as database uniqueness checks.
///
/// # Example
///
/// ```rust,ignore
/// use rustapi_rs::prelude::*;
/// use rustapi_validate::v2::prelude::*;
///
/// #[derive(Deserialize, Validate, AsyncValidate)]
/// struct CreateUser {
/// #[validate(email)]
/// email: String,
///
/// #[validate(async_unique(table = "users", column = "email"))]
/// username: String,
/// }
///
/// async fn register(AsyncValidatedJson(body): AsyncValidatedJson<CreateUser>) -> impl IntoResponse {
/// // body is validated asynchronously (e.g. checked existing email in DB)
/// }
/// ```
#[derive(Debug, Clone, Copy, Default)]
pub struct AsyncValidatedJson<T>(pub T);

impl<T> AsyncValidatedJson<T> {
/// Create a new AsyncValidatedJson wrapper
pub fn new(value: T) -> Self {
Self(value)
}

/// Get the inner value
pub fn into_inner(self) -> T {
self.0
}
}

impl<T> Deref for AsyncValidatedJson<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl<T> DerefMut for AsyncValidatedJson<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

impl<T> From<T> for AsyncValidatedJson<T> {
fn from(value: T) -> Self {
AsyncValidatedJson(value)
}
}

impl<T: Serialize> IntoResponse for AsyncValidatedJson<T> {
fn into_response(self) -> crate::response::Response {
Json(self.0).into_response()
}
}

impl<T: DeserializeOwned + AsyncValidate + Send + Sync> FromRequest for AsyncValidatedJson<T> {
async fn from_request(req: &mut Request) -> Result<Self> {
req.load_body().await?;

let body = req
.take_body()
.ok_or_else(|| ApiError::internal("Body already consumed"))?;

let value: T = json::from_slice(&body)?;

// Create validation context from request
// TODO: Extract validators from App State
let ctx = ValidationContext::default();
Comment on lines +378 to +380
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The code contains a TODO comment about extracting validators from App State. This indicates incomplete functionality. The ValidationContext::default() is used, which may not have access to necessary resources like database connections that are required for async validation rules (e.g., uniqueness checks).

This could cause async validators to fail silently or not work as expected in production. Consider either:

  1. Implementing the App State integration before merging
  2. Documenting this limitation clearly in the documentation
  3. Making it clear that users must provide their own validation context setup

Copilot uses AI. Check for mistakes.

// Perform full validation (sync + async)
if let Err(errors) = value.validate_full(&ctx).await {
// Convert v2 ValidationErrors to ApiError
let field_errors: Vec<crate::error::FieldError> = errors
.fields
.iter()
.flat_map(|(field, errs)| {
let field_name = field.to_string();
errs.iter().map(move |e| crate::error::FieldError {
field: field_name.clone(),
code: e.code.to_string(),
message: e.message.clone(),
})
})
.collect();

return Err(ApiError::validation(field_errors));
Comment on lines +384 to +398
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The error conversion logic here is duplicated from the convert_v2_errors function in crates/rustapi-core/src/validation.rs (lines 43-57). This violates the DRY (Don't Repeat Yourself) principle and creates a maintenance burden.

Instead of duplicating this conversion logic, this code should use the existing convert_v2_errors helper function from the validation module, like so:

if let Err(errors) = value.validate_full(&ctx).await {
    return Err(crate::validation::convert_v2_errors(errors));
}

This would ensure consistency in error format and make maintenance easier.

Suggested change
// Convert v2 ValidationErrors to ApiError
let field_errors: Vec<crate::error::FieldError> = errors
.fields
.iter()
.flat_map(|(field, errs)| {
let field_name = field.to_string();
errs.iter().map(move |e| crate::error::FieldError {
field: field_name.clone(),
code: e.code.to_string(),
message: e.message.clone(),
})
})
.collect();
return Err(ApiError::validation(field_errors));
return Err(crate::validation::convert_v2_errors(errors));

Copilot uses AI. Check for mistakes.
}

Ok(AsyncValidatedJson(value))
}
}

/// Query string extractor
///
/// Parses the query string into type `T`.
Expand Down
6 changes: 4 additions & 2 deletions crates/rustapi-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub mod sse;
pub mod static_files;
pub mod stream;
pub mod typed_path;
pub mod validation;
#[macro_use]
mod tracing_macros;

Expand All @@ -97,8 +98,8 @@ pub use error::{get_environment, ApiError, Environment, FieldError, Result};
#[cfg(feature = "cookies")]
pub use extract::Cookies;
pub use extract::{
Body, BodyStream, ClientIp, Extension, FromRequest, FromRequestParts, HeaderValue, Headers,
Json, Path, Query, State, Typed, ValidatedJson,
AsyncValidatedJson, Body, BodyStream, ClientIp, Extension, FromRequest, FromRequestParts,
HeaderValue, Headers, Json, Path, Query, State, Typed, ValidatedJson,
};
pub use handler::{
delete_route, get_route, patch_route, post_route, put_route, Handler, HandlerService, Route,
Expand All @@ -125,3 +126,4 @@ pub use sse::{sse_response, KeepAlive, Sse, SseEvent};
pub use static_files::{serve_dir, StaticFile, StaticFileConfig};
pub use stream::{StreamBody, StreamingBody, StreamingConfig};
pub use typed_path::TypedPath;
pub use validation::Validatable;
Loading
Loading