Skip to content

Commit a4b1a7b

Browse files
authored
Merge pull request #56 from Tuntii/0.1.188
Add unified validation system with async support
2 parents 800eaff + 6d37e53 commit a4b1a7b

File tree

12 files changed

+521
-63
lines changed

12 files changed

+521
-63
lines changed

Cargo.lock

Lines changed: 19 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: 12 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.191"
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"
@@ -118,3 +118,4 @@ h3-quinn = "0.0.10"
118118
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
119119
rustls-pemfile = "2.2"
120120
rcgen = "0.13"
121+

crates/rustapi-core/Cargo.toml

Lines changed: 3 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,5 @@ 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+
100+

crates/rustapi-core/src/extract.rs

Lines changed: 109 additions & 6 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,11 +265,8 @@ 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());
270-
}
268+
// Then, validate it using the unified Validatable trait
269+
value.do_validate()?;
271270

272271
Ok(ValidatedJson(value))
273272
}
@@ -299,6 +298,110 @@ impl<T: Serialize> IntoResponse for ValidatedJson<T> {
299298
}
300299
}
301300

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