Skip to main content

vld_axum/
lib.rs

1//! # vld-axum — Axum integration for the `vld` validation library
2//!
3//! Provides extractors that validate request data using `vld` schemas:
4//!
5//! | Extractor | Replaces | Source |
6//! |---|---|---|
7//! | [`VldJson<T>`] | `axum::Json<T>` | JSON request body |
8//! | [`VldQuery<T>`] | `axum::extract::Query<T>` | URL query parameters |
9//! | [`VldPath<T>`] | `axum::extract::Path<T>` | URL path parameters |
10//! | [`VldForm<T>`] | `axum::extract::Form<T>` | URL-encoded form body |
11//! | [`VldHeaders<T>`] | manual header extraction | HTTP headers |
12//! | [`VldCookie<T>`] | manual cookie parsing | Cookie values |
13//!
14//! All extractors return **422 Unprocessable Entity** on validation failure.
15//!
16//! # Quick example
17//!
18//! ```ignore
19//! use axum::{Router, routing::post};
20//! use vld::prelude::*;
21//! use vld_axum::{VldPath, VldQuery, VldJson, VldHeaders};
22//!
23//! vld::schema! {
24//!     #[derive(Debug)]
25//!     pub struct PathParams {
26//!         pub id: i64 => vld::number().int().min(1),
27//!     }
28//! }
29//!
30//! vld::schema! {
31//!     #[derive(Debug)]
32//!     pub struct Auth {
33//!         pub authorization: String => vld::string().min(1),
34//!     }
35//! }
36//!
37//! vld::schema! {
38//!     #[derive(Debug)]
39//!     pub struct Body {
40//!         pub name: String => vld::string().min(2),
41//!     }
42//! }
43//!
44//! async fn handler(
45//!     VldPath(path): VldPath<PathParams>,
46//!     VldHeaders(headers): VldHeaders<Auth>,
47//!     VldJson(body): VldJson<Body>,
48//! ) -> String {
49//!     format!("id={} auth={} name={}", path.id, headers.authorization, body.name)
50//! }
51//! ```
52
53use axum::extract::{FromRequest, FromRequestParts, Request};
54use axum::response::{IntoResponse, Response};
55use http::request::Parts;
56use http::StatusCode;
57
58// ============================= Rejection =====================================
59
60/// Rejection type returned when validation fails.
61///
62/// Used by all `Vld*` extractors in this crate.
63pub struct VldJsonRejection {
64    error: vld::error::VldError,
65}
66
67impl VldJsonRejection {
68    /// Get a reference to the underlying `VldError`.
69    pub fn error(&self) -> &vld::error::VldError {
70        &self.error
71    }
72}
73
74impl IntoResponse for VldJsonRejection {
75    fn into_response(self) -> Response {
76        let body = vld_http_common::format_vld_error(&self.error);
77
78        (
79            StatusCode::UNPROCESSABLE_ENTITY,
80            [(http::header::CONTENT_TYPE, "application/json")],
81            body.to_string(),
82        )
83            .into_response()
84    }
85}
86
87impl std::fmt::Display for VldJsonRejection {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "Validation failed: {}", self.error)
90    }
91}
92
93impl std::fmt::Debug for VldJsonRejection {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("VldJsonRejection")
96            .field("error", &self.error)
97            .finish()
98    }
99}
100
101// ============================= VldJson =======================================
102
103/// Axum extractor that validates **JSON request bodies**.
104///
105/// Drop-in replacement for `axum::Json<T>`.
106pub struct VldJson<T>(pub T);
107
108impl<S, T> FromRequest<S> for VldJson<T>
109where
110    S: Send + Sync,
111    T: vld::schema::VldParse,
112{
113    type Rejection = VldJsonRejection;
114
115    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
116        let body = axum::body::to_bytes(req.into_body(), usize::MAX)
117            .await
118            .map_err(|_| VldJsonRejection {
119                error: vld::error::VldError::single(
120                    vld::error::IssueCode::ParseError,
121                    "Failed to read request body",
122                ),
123            })?;
124
125        let value: serde_json::Value =
126            serde_json::from_slice(&body).map_err(|e| VldJsonRejection {
127                error: vld::error::VldError::single(
128                    vld::error::IssueCode::ParseError,
129                    format!("Invalid JSON: {}", e),
130                ),
131            })?;
132
133        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
134
135        Ok(VldJson(parsed))
136    }
137}
138
139// ============================= VldQuery ======================================
140
141/// Axum extractor that validates **URL query parameters**.
142///
143/// Drop-in replacement for `axum::extract::Query<T>`.
144///
145/// Values are coerced: `"42"` → number, `"true"`/`"false"` → boolean, empty → null.
146pub struct VldQuery<T>(pub T);
147
148impl<S, T> FromRequestParts<S> for VldQuery<T>
149where
150    S: Send + Sync,
151    T: vld::schema::VldParse,
152{
153    type Rejection = VldJsonRejection;
154
155    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
156        let query_string = parts.uri.query().unwrap_or("");
157        let value = query_string_to_json(query_string);
158
159        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
160
161        Ok(VldQuery(parsed))
162    }
163}
164
165// ============================= VldPath =======================================
166
167/// Axum extractor that validates **URL path parameters**.
168///
169/// Drop-in replacement for `axum::extract::Path<T>`.
170///
171/// Path segment values are coerced the same way as query parameters.
172///
173/// # Example
174///
175/// ```ignore
176/// // Route: /users/{id}/posts/{post_id}
177/// vld::schema! {
178///     #[derive(Debug)]
179///     pub struct PostPath {
180///         pub id: i64 => vld::number().int().min(1),
181///         pub post_id: i64 => vld::number().int().min(1),
182///     }
183/// }
184///
185/// async fn get_post(VldPath(p): VldPath<PostPath>) -> String {
186///     format!("user {} post {}", p.id, p.post_id)
187/// }
188/// ```
189pub struct VldPath<T>(pub T);
190
191impl<S, T> FromRequestParts<S> for VldPath<T>
192where
193    S: Send + Sync,
194    T: vld::schema::VldParse,
195{
196    type Rejection = VldJsonRejection;
197
198    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
199        let raw =
200            axum::extract::Path::<std::collections::HashMap<String, String>>::from_request_parts(
201                parts, state,
202            )
203            .await
204            .map_err(|e| VldJsonRejection {
205                error: vld::error::VldError::single(
206                    vld::error::IssueCode::ParseError,
207                    format!("Path parameter error: {}", e),
208                ),
209            })?;
210
211        let mut map = serde_json::Map::new();
212        for (k, v) in raw.0 {
213            map.insert(k, coerce_value(&v));
214        }
215        let value = serde_json::Value::Object(map);
216
217        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
218
219        Ok(VldPath(parsed))
220    }
221}
222
223// ============================= VldForm =======================================
224
225/// Axum extractor that validates **URL-encoded form bodies**
226/// (`application/x-www-form-urlencoded`).
227///
228/// Drop-in replacement for `axum::extract::Form<T>`.
229///
230/// Values are coerced the same way as query parameters.
231///
232/// # Example
233///
234/// ```ignore
235/// vld::schema! {
236///     #[derive(Debug)]
237///     pub struct LoginForm {
238///         pub username: String => vld::string().min(3).max(50),
239///         pub password: String => vld::string().min(8),
240///     }
241/// }
242///
243/// async fn login(VldForm(form): VldForm<LoginForm>) -> String {
244///     format!("Welcome, {}!", form.username)
245/// }
246/// ```
247pub struct VldForm<T>(pub T);
248
249impl<S, T> FromRequest<S> for VldForm<T>
250where
251    S: Send + Sync,
252    T: vld::schema::VldParse,
253{
254    type Rejection = VldJsonRejection;
255
256    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
257        let body = axum::body::to_bytes(req.into_body(), usize::MAX)
258            .await
259            .map_err(|_| VldJsonRejection {
260                error: vld::error::VldError::single(
261                    vld::error::IssueCode::ParseError,
262                    "Failed to read request body",
263                ),
264            })?;
265
266        let body_str = std::str::from_utf8(&body).map_err(|_| VldJsonRejection {
267            error: vld::error::VldError::single(
268                vld::error::IssueCode::ParseError,
269                "Form body is not valid UTF-8",
270            ),
271        })?;
272
273        let value = query_string_to_json(body_str);
274
275        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
276
277        Ok(VldForm(parsed))
278    }
279}
280
281// ============================= VldHeaders ====================================
282
283/// Axum extractor that validates **HTTP headers**.
284///
285/// Header names are normalised to snake_case for schema matching:
286/// `Content-Type` → `content_type`, `X-Request-Id` → `x_request_id`.
287///
288/// Values are coerced: `"42"` → number, `"true"` → boolean, etc.
289///
290/// # Example
291///
292/// ```ignore
293/// vld::schema! {
294///     #[derive(Debug)]
295///     pub struct RequiredHeaders {
296///         pub authorization: String => vld::string().min(1),
297///         pub x_request_id: Option<String> => vld::string().uuid().optional(),
298///     }
299/// }
300///
301/// async fn handler(VldHeaders(h): VldHeaders<RequiredHeaders>) -> String {
302///     format!("auth={}", h.authorization)
303/// }
304/// ```
305pub struct VldHeaders<T>(pub T);
306
307impl<S, T> FromRequestParts<S> for VldHeaders<T>
308where
309    S: Send + Sync,
310    T: vld::schema::VldParse,
311{
312    type Rejection = VldJsonRejection;
313
314    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
315        let value = headers_to_json(&parts.headers);
316
317        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
318
319        Ok(VldHeaders(parsed))
320    }
321}
322
323// ============================= VldCookie =====================================
324
325/// Axum extractor that validates **cookie values** from the `Cookie` header.
326///
327/// Cookie names are used as-is for schema field matching.
328/// Values are coerced the same way as query parameters.
329///
330/// # Example
331///
332/// ```ignore
333/// vld::schema! {
334///     #[derive(Debug)]
335///     pub struct SessionCookies {
336///         pub session_id: String => vld::string().min(1),
337///         pub theme: Option<String> => vld::string().optional(),
338///     }
339/// }
340///
341/// async fn dashboard(VldCookie(c): VldCookie<SessionCookies>) -> String {
342///     format!("session={}", c.session_id)
343/// }
344/// ```
345pub struct VldCookie<T>(pub T);
346
347impl<S, T> FromRequestParts<S> for VldCookie<T>
348where
349    S: Send + Sync,
350    T: vld::schema::VldParse,
351{
352    type Rejection = VldJsonRejection;
353
354    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
355        let cookie_header = parts
356            .headers
357            .get(http::header::COOKIE)
358            .and_then(|v| v.to_str().ok())
359            .unwrap_or("");
360
361        let value = cookies_to_json(cookie_header);
362
363        let parsed = T::vld_parse_value(&value).map_err(|error| VldJsonRejection { error })?;
364
365        Ok(VldCookie(parsed))
366    }
367}
368
369// ========================= Helper functions ==================================
370
371use vld_http_common::{coerce_value, cookies_to_json, query_string_to_json};
372
373/// Build a JSON object from HTTP headers.
374///
375/// Header names are normalised: `Content-Type` → `content_type`.
376fn headers_to_json(headers: &http::HeaderMap) -> serde_json::Value {
377    let mut map = serde_json::Map::new();
378
379    for (name, value) in headers.iter() {
380        let key = name.as_str().replace('-', "_");
381        if let Ok(v) = value.to_str() {
382            map.insert(key, coerce_value(v));
383        }
384    }
385
386    serde_json::Value::Object(map)
387}
388
389/// Prelude — import everything you need.
390pub mod prelude {
391    pub use crate::{VldCookie, VldForm, VldHeaders, VldJson, VldJsonRejection, VldPath, VldQuery};
392    pub use vld::prelude::*;
393}