Skip to main content

structured_proxy/transcode/
body.rs

1//! Request body parsing.
2//!
3//! Supports JSON (`application/json`) and form-urlencoded
4//! (`application/x-www-form-urlencoded`) request bodies.
5//! Empty bodies are treated as empty JSON objects.
6
7use axum::http::HeaderMap;
8use serde_json::Value;
9
10/// Parse request body bytes into a JSON `Value` based on content type.
11///
12/// - `application/x-www-form-urlencoded` → parse form fields into JSON object
13/// - `application/json` or anything else → parse as JSON
14/// - Empty body → `{}`
15pub fn parse_body(content_type: Option<&str>, body: &[u8]) -> Result<Value, BodyError> {
16    if body.is_empty() {
17        return Ok(Value::Object(serde_json::Map::new()));
18    }
19
20    match content_type {
21        Some(ct) if ct.starts_with("application/x-www-form-urlencoded") => {
22            let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(body)
23                .map_err(|e| BodyError::FormDecode(e.to_string()))?;
24            let mut map = serde_json::Map::new();
25            for (key, value) in pairs {
26                map.insert(key, Value::String(value));
27            }
28            Ok(Value::Object(map))
29        }
30        _ => serde_json::from_slice(body).map_err(|e| BodyError::JsonDecode(e.to_string())),
31    }
32}
33
34/// Extract content type from headers (just the media type, no parameters).
35pub fn content_type(headers: &HeaderMap) -> Option<&str> {
36    headers
37        .get("content-type")
38        .and_then(|v| v.to_str().ok())
39        .map(|ct| ct.split(';').next().unwrap_or(ct).trim())
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum BodyError {
44    #[error("invalid JSON: {0}")]
45    JsonDecode(String),
46    #[error("invalid form data: {0}")]
47    FormDecode(String),
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn test_empty_body() {
56        let result = parse_body(Some("application/json"), b"").unwrap();
57        assert_eq!(result, serde_json::json!({}));
58    }
59
60    #[test]
61    fn test_json_body() {
62        let body = br#"{"username":"alice","password":"secret"}"#;
63        let result = parse_body(Some("application/json"), body).unwrap();
64        assert_eq!(result["username"], "alice");
65        assert_eq!(result["password"], "secret");
66    }
67
68    #[test]
69    fn test_form_urlencoded_body() {
70        let body =
71            b"grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fexample.com";
72        let result = parse_body(Some("application/x-www-form-urlencoded"), body).unwrap();
73        assert_eq!(result["grant_type"], "authorization_code");
74        assert_eq!(result["code"], "abc123");
75        assert_eq!(result["redirect_uri"], "https://example.com");
76    }
77
78    #[test]
79    fn test_form_with_charset() {
80        let body = b"key=value";
81        let result = parse_body(
82            Some("application/x-www-form-urlencoded; charset=utf-8"),
83            body,
84        )
85        .unwrap();
86        assert_eq!(result["key"], "value");
87    }
88
89    #[test]
90    fn test_no_content_type_parses_as_json() {
91        let body = br#"{"foo":"bar"}"#;
92        let result = parse_body(None, body).unwrap();
93        assert_eq!(result["foo"], "bar");
94    }
95
96    #[test]
97    fn test_invalid_json() {
98        let body = b"not json";
99        assert!(parse_body(Some("application/json"), body).is_err());
100    }
101
102    #[test]
103    fn test_content_type_extraction() {
104        let mut headers = HeaderMap::new();
105        headers.insert(
106            "content-type",
107            "application/json; charset=utf-8".parse().unwrap(),
108        );
109        assert_eq!(content_type(&headers), Some("application/json"));
110    }
111
112    #[test]
113    fn test_content_type_missing() {
114        let headers = HeaderMap::new();
115        assert_eq!(content_type(&headers), None);
116    }
117}