Skip to main content

vld_http_common/
lib.rs

1//! # vld-http-common — Shared HTTP helpers for `vld` web integrations
2//!
3//! This crate provides common utility functions used by `vld-axum`,
4//! `vld-actix`, `vld-rocket`, `vld-poem`, and `vld-warp`.
5//!
6//! **Not intended for direct use by end users** — import via the
7//! framework-specific crate instead.
8
9/// Coerce a raw string value into a typed JSON value.
10///
11/// - `""` → `Null`
12/// - `"true"` / `"false"` (case-insensitive) → `Bool`
13/// - `"null"` (case-insensitive) → `Null`
14/// - Integer-looking → `Number` (i64)
15/// - Float-looking → `Number` (f64)
16/// - Everything else → `String`
17pub fn coerce_value(raw: &str) -> serde_json::Value {
18    if raw.is_empty() {
19        return serde_json::Value::Null;
20    }
21
22    if raw.eq_ignore_ascii_case("true") {
23        return serde_json::Value::Bool(true);
24    }
25    if raw.eq_ignore_ascii_case("false") {
26        return serde_json::Value::Bool(false);
27    }
28    if raw.eq_ignore_ascii_case("null") {
29        return serde_json::Value::Null;
30    }
31
32    if let Ok(n) = raw.parse::<i64>() {
33        return serde_json::Value::Number(n.into());
34    }
35
36    if let Ok(f) = raw.parse::<f64>() {
37        if f.is_finite() {
38            if let Some(n) = serde_json::Number::from_f64(f) {
39                return serde_json::Value::Number(n);
40            }
41        }
42    }
43
44    serde_json::Value::String(raw.to_string())
45}
46
47/// Parse a URL query string into a `serde_json::Map`.
48///
49/// Each `key=value` pair is URL-decoded and the value is coerced via
50/// [`coerce_value`]. Empty pairs are skipped.
51pub fn parse_query_string(query: &str) -> serde_json::Map<String, serde_json::Value> {
52    let mut map = serde_json::Map::new();
53
54    if query.is_empty() {
55        return map;
56    }
57
58    for pair in query.split('&') {
59        if pair.is_empty() {
60            continue;
61        }
62        let (key, raw_value) = match pair.split_once('=') {
63            Some((k, v)) => (k, v),
64            None => (pair, ""),
65        };
66
67        let key = url_decode(key);
68        let raw_value = url_decode(raw_value);
69
70        map.insert(key, coerce_value(&raw_value));
71    }
72
73    map
74}
75
76/// Parse a URL query string into a `serde_json::Value::Object`.
77///
78/// Convenience wrapper around [`parse_query_string`].
79pub fn query_string_to_json(query: &str) -> serde_json::Value {
80    serde_json::Value::Object(parse_query_string(query))
81}
82
83/// Build a JSON object from a `Cookie` header value.
84///
85/// Cookie names are used as-is. Values are coerced via [`coerce_value`].
86pub fn cookies_to_json(cookie_header: &str) -> serde_json::Value {
87    let mut map = serde_json::Map::new();
88
89    if cookie_header.is_empty() {
90        return serde_json::Value::Object(map);
91    }
92
93    for cookie in cookie_header.split(';') {
94        let cookie = cookie.trim();
95        if cookie.is_empty() {
96            continue;
97        }
98        let (name, value) = match cookie.split_once('=') {
99            Some((n, v)) => (n.trim(), v.trim()),
100            None => (cookie.trim(), ""),
101        };
102        map.insert(name.to_string(), coerce_value(value));
103    }
104
105    serde_json::Value::Object(map)
106}
107
108// ---------------------------------------------------------------------------
109// Error response schemas (defined via vld::schema!)
110// ---------------------------------------------------------------------------
111
112use serde::Serialize;
113
114vld::schema! {
115    /// Simple error body: `{ "error": "..." }`.
116    ///
117    /// Used for generic HTTP errors such as "Invalid UTF-8", "Payload too large",
118    /// "Not Found", "Bad Request", etc.
119    #[derive(Debug, Clone, Serialize)]
120    pub struct ErrorBody {
121        pub error: String => vld::string(),
122    }
123}
124
125vld::schema! {
126    /// Error body with a message: `{ "error": "...", "message": "..." }`.
127    ///
128    /// Used for JSON parse errors that include a description of what went wrong.
129    #[derive(Debug, Clone, Serialize)]
130    pub struct ErrorWithMessage {
131        pub error: String   => vld::string(),
132        pub message: String => vld::string(),
133    }
134}
135
136vld::schema! {
137    /// A single validation issue: `{ "path": "...", "message": "..." }`.
138    #[derive(Debug, Clone, Serialize)]
139    pub struct ValidationIssue {
140        pub path: String    => vld::string(),
141        pub message: String => vld::string(),
142    }
143}
144
145vld::schema! {
146    /// A validation issue with an error code:
147    /// `{ "path": "...", "message": "...", "code": "..." }`.
148    #[derive(Debug, Clone, Serialize)]
149    pub struct ValidationIssueWithCode {
150        pub path: String    => vld::string(),
151        pub message: String => vld::string(),
152        pub code: String    => vld::string(),
153    }
154}
155
156vld::schema! {
157    /// Validation error response body:
158    /// `{ "error": "Validation failed", "issues": [...] }`.
159    #[derive(Debug, Clone, Serialize)]
160    pub struct ValidationErrorBody {
161        pub error: String => vld::string(),
162        pub issues: Vec<ValidationIssue> => vld::array(vld::nested(ValidationIssue::parse_value)),
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Formatting helpers
168// ---------------------------------------------------------------------------
169
170/// Format a [`VldError`](vld::error::VldError) into a list of
171/// [`ValidationIssue`] structs.
172pub fn format_issues(err: &vld::error::VldError) -> Vec<ValidationIssue> {
173    err.issues
174        .iter()
175        .map(|i| {
176            let path: String = i
177                .path
178                .iter()
179                .map(|p| p.to_string())
180                .collect::<Vec<_>>()
181                .join(".");
182            ValidationIssue {
183                path,
184                message: i.message.clone(),
185            }
186        })
187        .collect()
188}
189
190/// Format a [`VldError`](vld::error::VldError) into a JSON object with
191/// `"error"` and `"issues"` keys — ready to be sent as a 422 response body.
192///
193/// Internally constructs a [`ValidationErrorBody`] and serializes it.
194pub fn format_vld_error(err: &vld::error::VldError) -> serde_json::Value {
195    let body = ValidationErrorBody {
196        error: "Validation failed".into(),
197        issues: format_issues(err),
198    };
199    serde_json::to_value(body).expect("ValidationErrorBody serialization cannot fail")
200}
201
202/// Format issues with an additional `"code"` key from
203/// [`IssueCode::key()`](vld::error::IssueCode::key).
204///
205/// Returns a list of [`ValidationIssueWithCode`] structs.
206pub fn format_issues_with_code(err: &vld::error::VldError) -> Vec<ValidationIssueWithCode> {
207    err.issues
208        .iter()
209        .map(|issue| {
210            let path: String = issue
211                .path
212                .iter()
213                .map(|p| p.to_string())
214                .collect::<Vec<_>>()
215                .join("");
216            ValidationIssueWithCode {
217                path,
218                message: issue.message.clone(),
219                code: issue.code.key().to_string(),
220            }
221        })
222        .collect()
223}
224
225/// Minimal percent-decode for URL query parameters.
226///
227/// Handles `%XX` hex encoding and `+` → space conversion.
228pub fn url_decode(input: &str) -> String {
229    let s = input.replace('+', " ");
230    let mut result = String::with_capacity(s.len());
231    let mut chars = s.chars();
232    while let Some(c) = chars.next() {
233        if c == '%' {
234            let hex: String = chars.by_ref().take(2).collect();
235            if let Ok(byte) = u8::from_str_radix(&hex, 16) {
236                result.push(byte as char);
237            } else {
238                result.push('%');
239                result.push_str(&hex);
240            }
241        } else {
242            result.push(c);
243        }
244    }
245    result
246}
247
248// ---------------------------------------------------------------------------
249// Standard HTTP error response helpers
250// ---------------------------------------------------------------------------
251
252/// Build a JSON error body for invalid JSON parse errors.
253///
254/// Returns `{ "error": "Invalid JSON", "message": "..." }`.
255///
256/// Internally constructs an [`ErrorWithMessage`] and serializes it.
257pub fn format_json_parse_error(message: &str) -> serde_json::Value {
258    serde_json::to_value(ErrorWithMessage {
259        error: "Invalid JSON".into(),
260        message: message.into(),
261    })
262    .expect("ErrorWithMessage serialization cannot fail")
263}
264
265/// Build a JSON error body for invalid UTF-8 payloads.
266///
267/// Returns `{ "error": "Invalid UTF-8" }`.
268///
269/// Internally constructs an [`ErrorBody`] and serializes it.
270pub fn format_utf8_error() -> serde_json::Value {
271    serde_json::to_value(ErrorBody {
272        error: "Invalid UTF-8".into(),
273    })
274    .expect("ErrorBody serialization cannot fail")
275}
276
277/// Build a JSON error body for payloads that exceed the size limit.
278///
279/// Returns `{ "error": "Payload too large" }`.
280///
281/// Internally constructs an [`ErrorBody`] and serializes it.
282pub fn format_payload_too_large() -> serde_json::Value {
283    serde_json::to_value(ErrorBody {
284        error: "Payload too large".into(),
285    })
286    .expect("ErrorBody serialization cannot fail")
287}
288
289/// Build a generic JSON error body with a custom error string.
290///
291/// Returns `{ "error": "<error>" }`.
292///
293/// Internally constructs an [`ErrorBody`] and serializes it.
294pub fn format_generic_error(error: &str) -> serde_json::Value {
295    serde_json::to_value(ErrorBody {
296        error: error.into(),
297    })
298    .expect("ErrorBody serialization cannot fail")
299}
300
301/// Extract parameter names from a route pattern like `/users/{id}/posts/{post_id}`.
302pub fn extract_path_param_names(pattern: &str) -> Vec<String> {
303    let mut names = Vec::new();
304    let mut chars = pattern.chars().peekable();
305    while let Some(c) = chars.next() {
306        if c == '{' {
307            let name: String = chars.by_ref().take_while(|&c| c != '}').collect();
308            if !name.is_empty() {
309                names.push(name);
310            }
311        }
312    }
313    names
314}