|
| 1 | +use hyper::{ |
| 2 | + HeaderMap, |
| 3 | + header::{CONTENT_TYPE, HeaderValue}, |
| 4 | +}; |
| 5 | +use tucana::shared::{ |
| 6 | + Value, number_value, |
| 7 | + value::Kind::{self, StringValue}, |
| 8 | +}; |
| 9 | + |
| 10 | +#[derive(Debug, Clone, Copy, Eq, PartialEq)] |
| 11 | +pub enum BodyFormat { |
| 12 | + Json, |
| 13 | + TextPlain, |
| 14 | + Unknown, |
| 15 | +} |
| 16 | + |
| 17 | +#[derive(Debug)] |
| 18 | +pub enum BodyParseError { |
| 19 | + UnsupportedContentType { observed: String }, |
| 20 | + InvalidUtf8(std::str::Utf8Error), |
| 21 | + InvalidJson(serde_json::Error), |
| 22 | +} |
| 23 | + |
| 24 | +impl std::fmt::Display for BodyParseError { |
| 25 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 26 | + match self { |
| 27 | + Self::UnsupportedContentType { observed } => { |
| 28 | + write!(f, "unsupported content type: {}", observed) |
| 29 | + } |
| 30 | + Self::InvalidUtf8(err) => write!(f, "invalid UTF-8 body: {}", err), |
| 31 | + Self::InvalidJson(err) => write!(f, "invalid JSON body: {}", err), |
| 32 | + } |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +impl std::error::Error for BodyParseError {} |
| 37 | + |
| 38 | +#[derive(Debug)] |
| 39 | +pub enum BodyEncodeError { |
| 40 | + UnsupportedContentType { observed: String }, |
| 41 | + InvalidJson(serde_json::Error), |
| 42 | +} |
| 43 | + |
| 44 | +impl std::fmt::Display for BodyEncodeError { |
| 45 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 46 | + match self { |
| 47 | + Self::UnsupportedContentType { observed } => { |
| 48 | + write!(f, "unsupported content type: {}", observed) |
| 49 | + } |
| 50 | + Self::InvalidJson(err) => write!(f, "failed to encode JSON body: {}", err), |
| 51 | + } |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +impl std::error::Error for BodyEncodeError {} |
| 56 | + |
| 57 | +pub fn parse_body_from_headers( |
| 58 | + headers: &HeaderMap<HeaderValue>, |
| 59 | + body: &[u8], |
| 60 | +) -> Result<Option<Value>, BodyParseError> { |
| 61 | + parse_body(get_content_type(headers), body) |
| 62 | +} |
| 63 | + |
| 64 | +pub fn parse_body( |
| 65 | + content_type: Option<&str>, |
| 66 | + body: &[u8], |
| 67 | +) -> Result<Option<Value>, BodyParseError> { |
| 68 | + if body.is_empty() { |
| 69 | + return Ok(None); |
| 70 | + } |
| 71 | + |
| 72 | + match classify_content_type(content_type) { |
| 73 | + BodyFormat::Json => parse_json_body(body), |
| 74 | + BodyFormat::TextPlain => parse_text_body(body), |
| 75 | + BodyFormat::Unknown => { |
| 76 | + // If there is no content type |
| 77 | + if content_type.is_none() |
| 78 | + && let Ok(value) = parse_text_body(body) |
| 79 | + { |
| 80 | + return Ok(value); |
| 81 | + } |
| 82 | + |
| 83 | + Err(BodyParseError::UnsupportedContentType { |
| 84 | + observed: content_type.unwrap_or("<missing>").to_string(), |
| 85 | + }) |
| 86 | + } |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +pub fn encode_body(content_type: Option<&str>, value: Value) -> Result<Vec<u8>, BodyEncodeError> { |
| 91 | + match classify_content_type(content_type) { |
| 92 | + BodyFormat::Json => encode_json_body(value), |
| 93 | + BodyFormat::TextPlain => encode_text_body(value), |
| 94 | + BodyFormat::Unknown => { |
| 95 | + // Missing content type falls back to JSON. |
| 96 | + if content_type.is_none() { |
| 97 | + return encode_json_body(value); |
| 98 | + } |
| 99 | + |
| 100 | + Err(BodyEncodeError::UnsupportedContentType { |
| 101 | + observed: content_type.unwrap_or("<missing>").to_string(), |
| 102 | + }) |
| 103 | + } |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +pub fn classify_content_type(content_type: Option<&str>) -> BodyFormat { |
| 108 | + let Some(raw) = content_type else { |
| 109 | + return BodyFormat::Unknown; |
| 110 | + }; |
| 111 | + |
| 112 | + let essence = raw |
| 113 | + .split(';') |
| 114 | + .next() |
| 115 | + .unwrap_or(raw) |
| 116 | + .trim() |
| 117 | + .to_ascii_lowercase(); |
| 118 | + |
| 119 | + if essence == "application/json" || essence.ends_with("+json") { |
| 120 | + return BodyFormat::Json; |
| 121 | + } |
| 122 | + |
| 123 | + if essence == "text/plain" { |
| 124 | + return BodyFormat::TextPlain; |
| 125 | + } |
| 126 | + |
| 127 | + BodyFormat::Unknown |
| 128 | +} |
| 129 | + |
| 130 | +fn parse_json_body(body: &[u8]) -> Result<Option<Value>, BodyParseError> { |
| 131 | + let json_value = |
| 132 | + serde_json::from_slice::<serde_json::Value>(body).map_err(BodyParseError::InvalidJson)?; |
| 133 | + Ok(Some(tucana::shared::helper::value::from_json_value( |
| 134 | + json_value, |
| 135 | + ))) |
| 136 | +} |
| 137 | + |
| 138 | +fn parse_text_body(body: &[u8]) -> Result<Option<Value>, BodyParseError> { |
| 139 | + let text = std::str::from_utf8(body).map_err(BodyParseError::InvalidUtf8)?; |
| 140 | + Ok(Some(Value { |
| 141 | + kind: Some(StringValue(text.to_string())), |
| 142 | + })) |
| 143 | +} |
| 144 | + |
| 145 | +fn encode_json_body(value: Value) -> Result<Vec<u8>, BodyEncodeError> { |
| 146 | + let json_val = tucana::shared::helper::value::to_json_value(value); |
| 147 | + serde_json::to_vec_pretty(&json_val).map_err(BodyEncodeError::InvalidJson) |
| 148 | +} |
| 149 | + |
| 150 | +fn encode_text_body(value: Value) -> Result<Vec<u8>, BodyEncodeError> { |
| 151 | + if let Some(text) = scalar_to_text(&value) { |
| 152 | + return Ok(text.into_bytes()); |
| 153 | + } |
| 154 | + |
| 155 | + // For lists/objects, return valid JSON text as the plain-text body. |
| 156 | + encode_json_body(value) |
| 157 | +} |
| 158 | + |
| 159 | +fn scalar_to_text(value: &Value) -> Option<String> { |
| 160 | + match value.kind.as_ref() { |
| 161 | + Some(Kind::NullValue(_)) | None => Some("null".to_string()), |
| 162 | + Some(Kind::BoolValue(v)) => Some(v.to_string()), |
| 163 | + Some(Kind::StringValue(v)) => Some(v.clone()), |
| 164 | + Some(Kind::NumberValue(v)) => match v.number.as_ref() { |
| 165 | + Some(number_value::Number::Integer(i)) => Some(i.to_string()), |
| 166 | + Some(number_value::Number::Float(f)) => Some(f.to_string()), |
| 167 | + None => Some("null".to_string()), |
| 168 | + }, |
| 169 | + _ => None, |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +fn get_content_type(headers: &HeaderMap<HeaderValue>) -> Option<&str> { |
| 174 | + headers.get(CONTENT_TYPE).and_then(|h| h.to_str().ok()) |
| 175 | +} |
| 176 | + |
| 177 | +#[cfg(test)] |
| 178 | +mod tests { |
| 179 | + use super::*; |
| 180 | + use tucana::shared::{NumberValue, Struct, Value}; |
| 181 | + |
| 182 | + #[test] |
| 183 | + fn classify_json_content_type_with_charset() { |
| 184 | + let format = classify_content_type(Some("application/json; charset=utf-8")); |
| 185 | + assert_eq!(format, BodyFormat::Json); |
| 186 | + } |
| 187 | + |
| 188 | + #[test] |
| 189 | + fn classify_vendor_json_content_type() { |
| 190 | + let format = classify_content_type(Some("application/problem+json")); |
| 191 | + assert_eq!(format, BodyFormat::Json); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn classify_text_plain_content_type() { |
| 196 | + let format = classify_content_type(Some("text/plain; charset=utf-8")); |
| 197 | + assert_eq!(format, BodyFormat::TextPlain); |
| 198 | + } |
| 199 | + |
| 200 | + #[test] |
| 201 | + fn parse_json_body_to_struct_value() { |
| 202 | + let body = br#"{"hello":"world","ok":true}"#; |
| 203 | + let parsed = parse_body(Some("application/json"), body).unwrap(); |
| 204 | + |
| 205 | + let Some(Value { |
| 206 | + kind: Some(Kind::StructValue(Struct { fields })), |
| 207 | + }) = parsed |
| 208 | + else { |
| 209 | + panic!("expected struct value"); |
| 210 | + }; |
| 211 | + |
| 212 | + assert!(fields.contains_key("hello")); |
| 213 | + assert!(fields.contains_key("ok")); |
| 214 | + } |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn parse_text_body_to_string_value() { |
| 218 | + let body = b"hello"; |
| 219 | + let parsed = parse_body(Some("text/plain"), body).unwrap(); |
| 220 | + |
| 221 | + let Some(Value { |
| 222 | + kind: Some(Kind::StringValue(v)), |
| 223 | + }) = parsed |
| 224 | + else { |
| 225 | + panic!("expected string value"); |
| 226 | + }; |
| 227 | + |
| 228 | + assert_eq!(v, "hello"); |
| 229 | + } |
| 230 | + |
| 231 | + #[test] |
| 232 | + fn parse_unsupported_content_type_fails() { |
| 233 | + let body = br#"<root />"#; |
| 234 | + let err = parse_body(Some("application/xml"), body).unwrap_err(); |
| 235 | + |
| 236 | + assert!(matches!(err, BodyParseError::UnsupportedContentType { .. })); |
| 237 | + } |
| 238 | + |
| 239 | + #[test] |
| 240 | + fn encode_json_body_from_struct_value() { |
| 241 | + let value = Value { |
| 242 | + kind: Some(Kind::StructValue(Struct { |
| 243 | + fields: [( |
| 244 | + "hello".to_string(), |
| 245 | + Value { |
| 246 | + kind: Some(Kind::StringValue("world".to_string())), |
| 247 | + }, |
| 248 | + )] |
| 249 | + .into_iter() |
| 250 | + .collect(), |
| 251 | + })), |
| 252 | + }; |
| 253 | + |
| 254 | + let encoded = encode_body(Some("application/json"), value).unwrap(); |
| 255 | + let parsed: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); |
| 256 | + |
| 257 | + assert_eq!(parsed["hello"], "world"); |
| 258 | + } |
| 259 | + |
| 260 | + #[test] |
| 261 | + fn encode_text_body_from_string_value() { |
| 262 | + let value = Value { |
| 263 | + kind: Some(Kind::StringValue("hello".to_string())), |
| 264 | + }; |
| 265 | + |
| 266 | + let encoded = encode_body(Some("text/plain"), value).unwrap(); |
| 267 | + assert_eq!(encoded, b"hello".to_vec()); |
| 268 | + } |
| 269 | + |
| 270 | + #[test] |
| 271 | + fn encode_text_body_from_number_value() { |
| 272 | + let value = Value { |
| 273 | + kind: Some(Kind::NumberValue(NumberValue { |
| 274 | + number: Some(number_value::Number::Integer(42)), |
| 275 | + })), |
| 276 | + }; |
| 277 | + |
| 278 | + let encoded = encode_body(Some("text/plain"), value).unwrap(); |
| 279 | + assert_eq!(encoded, b"42".to_vec()); |
| 280 | + } |
| 281 | + |
| 282 | + #[test] |
| 283 | + fn encode_text_body_from_struct_value_falls_back_to_json_text() { |
| 284 | + let value = Value { |
| 285 | + kind: Some(Kind::StructValue(Struct { |
| 286 | + fields: [( |
| 287 | + "answer".to_string(), |
| 288 | + Value { |
| 289 | + kind: Some(Kind::NumberValue(NumberValue { |
| 290 | + number: Some(number_value::Number::Integer(42)), |
| 291 | + })), |
| 292 | + }, |
| 293 | + )] |
| 294 | + .into_iter() |
| 295 | + .collect(), |
| 296 | + })), |
| 297 | + }; |
| 298 | + |
| 299 | + let encoded = encode_body(Some("text/plain"), value).unwrap(); |
| 300 | + let body_text = String::from_utf8(encoded).unwrap(); |
| 301 | + |
| 302 | + assert!(body_text.contains("\"answer\"")); |
| 303 | + assert!(body_text.contains("42")); |
| 304 | + } |
| 305 | + |
| 306 | + #[test] |
| 307 | + fn encode_unknown_content_type_fails() { |
| 308 | + let value = Value { |
| 309 | + kind: Some(Kind::StringValue("x".to_string())), |
| 310 | + }; |
| 311 | + |
| 312 | + let err = encode_body(Some("application/xml"), value).unwrap_err(); |
| 313 | + assert!(matches!( |
| 314 | + err, |
| 315 | + BodyEncodeError::UnsupportedContentType { .. } |
| 316 | + )); |
| 317 | + } |
| 318 | +} |
0 commit comments