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}