Skip to content

Commit 356f526

Browse files
committed
Add unified validation system with async support
Introduces a unified Validatable trait to support both legacy validator and new v2 validation engines, including async validation. Adds AsyncValidatedJson extractor for async validation scenarios, updates extractors and prelude, and provides conversion helpers for error normalization. Updates documentation and adds comprehensive tests for sync and async validation flows.
1 parent 800eaff commit 356f526

11 files changed

Lines changed: 516 additions & 62 deletions

File tree

Cargo.lock

Lines changed: 18 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ members = [
2121
]
2222

2323
[workspace.package]
24-
version = "0.1.15"
24+
version = "0.1.188"
2525
edition = "2021"
2626
authors = ["RustAPI Contributors"]
2727
license = "MIT OR Apache-2.0"
@@ -100,16 +100,16 @@ indicatif = "0.17"
100100
console = "0.15"
101101

102102
# Internal crates
103-
rustapi-core = { path = "crates/rustapi-core", version = "0.1.15", default-features = false }
104-
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.15" }
105-
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.15" }
106-
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.15", default-features = false }
107-
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.15" }
108-
rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.15" }
109-
rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.15" }
110-
rustapi-view = { path = "crates/rustapi-view", version = "0.1.15" }
111-
rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.15" }
112-
rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.15" }
103+
rustapi-core = { path = "crates/rustapi-core", version = "0.1.188", default-features = false }
104+
rustapi-macros = { path = "crates/rustapi-macros", version = "0.1.188" }
105+
rustapi-validate = { path = "crates/rustapi-validate", version = "0.1.188" }
106+
rustapi-openapi = { path = "crates/rustapi-openapi", version = "0.1.188", default-features = false }
107+
rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.188" }
108+
rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.188" }
109+
rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.188" }
110+
rustapi-view = { path = "crates/rustapi-view", version = "0.1.188" }
111+
rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.188" }
112+
rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.188" }
113113

114114
# HTTP/3 (QUIC)
115115
quinn = "0.11"

crates/rustapi-core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ brotli = { version = "6.0", optional = true }
5656
cookie = { version = "0.18", optional = true }
5757

5858
# Validation
59+
validator = { workspace = true }
5960
rustapi-validate = { workspace = true }
6061

6162
# Metrics (optional)
@@ -95,3 +96,4 @@ simd-json = ["dep:simd-json"]
9596
tracing = []
9697
http3 = ["dep:quinn", "dep:h3", "dep:h3-quinn", "dep:rustls", "dep:rustls-pemfile"]
9798
http3-dev = ["http3", "dep:rcgen"]
99+

crates/rustapi-core/src/extract.rs

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ use crate::json;
5959
use crate::request::Request;
6060
use crate::response::IntoResponse;
6161
use crate::stream::{StreamingBody, StreamingConfig};
62+
use crate::validation::Validatable;
6263
use bytes::Bytes;
6364
use http::{header, StatusCode};
65+
use rustapi_validate::v2::{AsyncValidate, ValidationContext};
6466

6567
use serde::de::DeserializeOwned;
6668
use serde::Serialize;
@@ -253,7 +255,7 @@ impl<T> ValidatedJson<T> {
253255
}
254256
}
255257

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

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

266-
// Then, validate it
267-
if let Err(validation_error) = rustapi_validate::Validate::validate(&value) {
268-
// Convert validation error to API error with 422 status
269-
return Err(validation_error.into());
268+
// Then, validate it using the unified Validatable trait
269+
if let Err(e) = value.do_validate() {
270+
return Err(e);
270271
}
271272

272273
Ok(ValidatedJson(value))
@@ -299,6 +300,110 @@ impl<T: Serialize> IntoResponse for ValidatedJson<T> {
299300
}
300301
}
301302

303+
/// Async validated JSON body extractor
304+
///
305+
/// Parses the request body as JSON, deserializes into type `T`, and validates
306+
/// using the `AsyncValidate` trait from `rustapi-validate`.
307+
///
308+
/// This extractor supports async validation rules, such as database uniqueness checks.
309+
///
310+
/// # Example
311+
///
312+
/// ```rust,ignore
313+
/// use rustapi_rs::prelude::*;
314+
/// use rustapi_validate::v2::prelude::*;
315+
///
316+
/// #[derive(Deserialize, Validate, AsyncValidate)]
317+
/// struct CreateUser {
318+
/// #[validate(email)]
319+
/// email: String,
320+
///
321+
/// #[validate(async_unique(table = "users", column = "email"))]
322+
/// username: String,
323+
/// }
324+
///
325+
/// async fn register(AsyncValidatedJson(body): AsyncValidatedJson<CreateUser>) -> impl IntoResponse {
326+
/// // body is validated asynchronously (e.g. checked existing email in DB)
327+
/// }
328+
/// ```
329+
#[derive(Debug, Clone, Copy, Default)]
330+
pub struct AsyncValidatedJson<T>(pub T);
331+
332+
impl<T> AsyncValidatedJson<T> {
333+
/// Create a new AsyncValidatedJson wrapper
334+
pub fn new(value: T) -> Self {
335+
Self(value)
336+
}
337+
338+
/// Get the inner value
339+
pub fn into_inner(self) -> T {
340+
self.0
341+
}
342+
}
343+
344+
impl<T> Deref for AsyncValidatedJson<T> {
345+
type Target = T;
346+
347+
fn deref(&self) -> &Self::Target {
348+
&self.0
349+
}
350+
}
351+
352+
impl<T> DerefMut for AsyncValidatedJson<T> {
353+
fn deref_mut(&mut self) -> &mut Self::Target {
354+
&mut self.0
355+
}
356+
}
357+
358+
impl<T> From<T> for AsyncValidatedJson<T> {
359+
fn from(value: T) -> Self {
360+
AsyncValidatedJson(value)
361+
}
362+
}
363+
364+
impl<T: Serialize> IntoResponse for AsyncValidatedJson<T> {
365+
fn into_response(self) -> crate::response::Response {
366+
Json(self.0).into_response()
367+
}
368+
}
369+
370+
impl<T: DeserializeOwned + AsyncValidate + Send + Sync> FromRequest for AsyncValidatedJson<T> {
371+
async fn from_request(req: &mut Request) -> Result<Self> {
372+
req.load_body().await?;
373+
374+
let body = req
375+
.take_body()
376+
.ok_or_else(|| ApiError::internal("Body already consumed"))?;
377+
378+
let value: T = json::from_slice(&body)?;
379+
380+
// Create validation context from request
381+
// TODO: Extract validators from App State
382+
let ctx = ValidationContext::default();
383+
384+
// Perform full validation (sync + async)
385+
if let Err(errors) = value.validate_full(&ctx).await {
386+
// Convert v2 ValidationErrors to ApiError
387+
let field_errors: Vec<crate::error::FieldError> = errors
388+
.fields
389+
.iter()
390+
.flat_map(|(field, errs)| {
391+
let field_name = field.to_string();
392+
errs.iter().map(move |e| crate::error::FieldError {
393+
field: field_name.clone(),
394+
code: e.code.to_string(),
395+
message: e.message.clone(),
396+
})
397+
})
398+
.collect();
399+
400+
return Err(ApiError::validation(field_errors));
401+
}
402+
403+
Ok(AsyncValidatedJson(value))
404+
}
405+
}
406+
302407
/// Query string extractor
303408
///
304409
/// Parses the query string into type `T`.

crates/rustapi-core/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ pub mod sse;
7676
pub mod static_files;
7777
pub mod stream;
7878
pub mod typed_path;
79+
pub mod validation;
7980
#[macro_use]
8081
mod tracing_macros;
8182

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

0 commit comments

Comments
 (0)