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/// Format a [`VldError`](vld::error::VldError) into a JSON array of issues.
109///
110/// Each issue is `{ "path": "...", "message": "..." }`.
111pub fn format_issues(err: &vld::error::VldError) -> Vec<serde_json::Value> {
112    err.issues
113        .iter()
114        .map(|i| {
115            let path: String = i
116                .path
117                .iter()
118                .map(|p| p.to_string())
119                .collect::<Vec<_>>()
120                .join(".");
121            serde_json::json!({
122                "path": path,
123                "message": i.message,
124            })
125        })
126        .collect()
127}
128
129/// Format a [`VldError`](vld::error::VldError) into a JSON object with
130/// `"error"` and `"issues"` keys — ready to be sent as a 422 response body.
131pub fn format_vld_error(err: &vld::error::VldError) -> serde_json::Value {
132    serde_json::json!({
133        "error": "Validation failed",
134        "issues": format_issues(err),
135    })
136}
137
138/// Format issues with an additional `"code"` key from
139/// [`IssueCode::key()`](vld::error::IssueCode::key).
140///
141/// Used by axum/actix where the error response includes `code`.
142pub fn format_issues_with_code(err: &vld::error::VldError) -> Vec<serde_json::Value> {
143    err.issues
144        .iter()
145        .map(|issue| {
146            let path: String = issue
147                .path
148                .iter()
149                .map(|p| p.to_string())
150                .collect::<Vec<_>>()
151                .join("");
152            serde_json::json!({
153                "path": path,
154                "message": issue.message,
155                "code": issue.code.key(),
156            })
157        })
158        .collect()
159}
160
161/// Minimal percent-decode for URL query parameters.
162///
163/// Handles `%XX` hex encoding and `+` → space conversion.
164pub fn url_decode(input: &str) -> String {
165    let s = input.replace('+', " ");
166    let mut result = String::with_capacity(s.len());
167    let mut chars = s.chars();
168    while let Some(c) = chars.next() {
169        if c == '%' {
170            let hex: String = chars.by_ref().take(2).collect();
171            if let Ok(byte) = u8::from_str_radix(&hex, 16) {
172                result.push(byte as char);
173            } else {
174                result.push('%');
175                result.push_str(&hex);
176            }
177        } else {
178            result.push(c);
179        }
180    }
181    result
182}
183
184/// Extract parameter names from a route pattern like `/users/{id}/posts/{post_id}`.
185pub fn extract_path_param_names(pattern: &str) -> Vec<String> {
186    let mut names = Vec::new();
187    let mut chars = pattern.chars().peekable();
188    while let Some(c) = chars.next() {
189        if c == '{' {
190            let name: String = chars.by_ref().take_while(|&c| c != '}').collect();
191            if !name.is_empty() {
192                names.push(name);
193            }
194        }
195    }
196    names
197}