1use bytes::Bytes;
4use http::StatusCode;
5use rskit_errors::{AppError, ErrorCode};
6use serde::de::DeserializeOwned;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
11pub struct ErrorResponse {
12 pub status: StatusCode,
14 pub headers: HashMap<String, String>,
18 pub body: String,
20}
21
22impl ErrorResponse {
23 #[must_use]
25 pub fn header(&self, name: &str) -> Option<&String> {
26 header_value(&self.headers, name)
27 }
28}
29
30pub struct Response {
32 pub status: StatusCode,
34
35 pub headers: HashMap<String, String>,
37
38 body: Bytes,
40}
41
42impl Response {
43 pub(crate) fn new(status: StatusCode, headers: HashMap<String, String>, body: Bytes) -> Self {
45 Self {
46 status,
47 headers,
48 body,
49 }
50 }
51
52 #[must_use]
54 pub fn status(&self) -> StatusCode {
55 self.status
56 }
57
58 #[must_use]
60 pub fn status_u16(&self) -> u16 {
61 self.status.as_u16()
62 }
63
64 #[must_use]
66 pub fn is_success(&self) -> bool {
67 self.status.is_success()
68 }
69
70 #[must_use]
72 pub fn headers(&self) -> &HashMap<String, String> {
73 &self.headers
74 }
75
76 #[must_use]
78 pub fn header(&self, name: &str) -> Option<&String> {
79 header_value(&self.headers, name)
80 }
81
82 #[must_use]
84 pub fn body_bytes(&self) -> &Bytes {
85 &self.body
86 }
87
88 pub fn into_bytes(self) -> Bytes {
90 self.body
91 }
92
93 pub fn text(self) -> rskit_errors::AppResult<String> {
95 String::from_utf8(self.body.to_vec())
96 .map_err(|e| AppError::new(ErrorCode::InvalidInput, format!("invalid utf8: {}", e)))
97 }
98
99 #[must_use]
101 pub fn text_or_diagnostic(self) -> String {
102 String::from_utf8(self.body.to_vec()).unwrap_or_else(|_| "<non-utf8 body>".to_string())
103 }
104
105 pub fn json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
107 serde_json::from_slice(&self.body).map_err(|e| {
108 AppError::new(
109 ErrorCode::InvalidInput,
110 format!("failed to parse json response: {}", e),
111 )
112 })
113 }
114
115 pub fn checked_text(self) -> rskit_errors::AppResult<String> {
117 self.error_for_status()?.text()
118 }
119
120 pub fn checked_text_with<F>(self, mapper: F) -> rskit_errors::AppResult<String>
122 where
123 F: FnOnce(ErrorResponse) -> AppError,
124 {
125 self.error_for_status_with(mapper)?.text()
126 }
127
128 pub fn checked_json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
130 self.error_for_status()?.json()
131 }
132
133 pub fn checked_json_with<T, F>(self, mapper: F) -> rskit_errors::AppResult<T>
135 where
136 T: DeserializeOwned,
137 F: FnOnce(ErrorResponse) -> AppError,
138 {
139 self.error_for_status_with(mapper)?.json()
140 }
141
142 pub fn error_for_status(self) -> rskit_errors::AppResult<Self> {
144 self.error_for_status_with(default_error_mapper)
145 }
146
147 pub fn error_for_status_with<F>(self, mapper: F) -> rskit_errors::AppResult<Self>
149 where
150 F: FnOnce(ErrorResponse) -> AppError,
151 {
152 if self.status.is_success() {
153 Ok(self)
154 } else {
155 let status = self.status;
156 let headers = self.headers;
157 let body = String::from_utf8(self.body.to_vec())
158 .unwrap_or_else(|_| "<non-utf8 body>".to_string());
159
160 Err(mapper(ErrorResponse {
161 status,
162 headers,
163 body,
164 }))
165 }
166 }
167}
168
169fn header_value<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a String> {
170 headers
171 .iter()
172 .find(|(key, _)| key.eq_ignore_ascii_case(name))
173 .map(|(_, value)| value)
174}
175
176fn default_error_mapper(response: ErrorResponse) -> AppError {
177 let status = response.status;
178 let code = match status.as_u16() {
179 400 => ErrorCode::InvalidInput,
180 401 => ErrorCode::Unauthorized,
181 403 => ErrorCode::Forbidden,
182 404 => ErrorCode::NotFound,
183 409 => ErrorCode::Conflict,
184 429 => ErrorCode::RateLimited,
185 500 | 502 | 503 | 504 => ErrorCode::Internal,
186 _ => ErrorCode::ExternalService,
187 };
188
189 AppError::new(
190 code,
191 format!(
192 "http error: {} {}",
193 status.as_u16(),
194 status.canonical_reason().unwrap_or("Unknown")
195 ),
196 )
197 .with_detail("status", status.as_u16().to_string())
198 .with_detail("body", response.body)
199}
200
201impl std::fmt::Debug for Response {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.debug_struct("Response")
204 .field("status", &self.status)
205 .field("headers", &self.headers)
206 .field("body_len", &self.body.len())
207 .finish()
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_response_status_check() {
217 let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from("ok"));
218 assert!(resp.is_success());
219
220 let resp = Response::new(
221 StatusCode::NOT_FOUND,
222 HashMap::new(),
223 Bytes::from("not found"),
224 );
225 assert!(!resp.is_success());
226 }
227
228 #[test]
229 fn test_response_header_case_insensitive() {
230 let mut headers = HashMap::new();
231 headers.insert("Content-Type".to_string(), "application/json".to_string());
232
233 let resp = Response::new(StatusCode::OK, headers, Bytes::new());
234 assert_eq!(
235 resp.header("content-type"),
236 Some(&"application/json".to_string())
237 );
238 }
239
240 #[test]
241 fn body_accessors_and_debug_do_not_expose_body_contents() {
242 let resp = Response::new(
243 StatusCode::CREATED,
244 HashMap::from([("x-id".to_string(), "123".to_string())]),
245 Bytes::from_static(b"created"),
246 );
247
248 assert_eq!(resp.body_bytes(), &Bytes::from_static(b"created"));
249 let debug = format!("{resp:?}");
250 assert!(debug.contains("body_len"));
251 assert!(!debug.contains("created"));
252 assert_eq!(resp.into_bytes(), Bytes::from_static(b"created"));
253 }
254
255 #[test]
256 fn test_response_json() {
257 let json_data = r#"{"key":"value"}"#;
258 let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from(json_data));
259
260 let parsed: serde_json::Value = resp.json().unwrap();
261 assert_eq!(parsed["key"], "value");
262 }
263
264 #[test]
265 fn checked_text_rejects_error_status_with_body_detail() {
266 let resp = Response::new(
267 StatusCode::TOO_MANY_REQUESTS,
268 HashMap::new(),
269 Bytes::from("slow down"),
270 );
271
272 let error = resp.checked_text().unwrap_err();
273 assert!(error.to_string().contains("http error: 429"));
274 assert_eq!(
275 error
276 .details()
277 .get("body")
278 .and_then(serde_json::Value::as_str),
279 Some("slow down")
280 );
281 }
282
283 #[test]
284 fn text_or_diagnostic_handles_non_utf8_body() {
285 let resp = Response::new(
286 StatusCode::BAD_GATEWAY,
287 HashMap::new(),
288 Bytes::from(vec![0xff]),
289 );
290
291 assert_eq!(resp.text_or_diagnostic(), "<non-utf8 body>");
292 }
293
294 #[test]
295 fn custom_status_mapper_receives_status_headers_and_body() {
296 let mut headers = HashMap::new();
297 headers.insert("x-request-id".to_string(), "abc".to_string());
298 let resp = Response::new(
299 StatusCode::BAD_GATEWAY,
300 headers,
301 Bytes::from("upstream unavailable"),
302 );
303
304 let error = resp
305 .error_for_status_with(|response| {
306 AppError::new(ErrorCode::ExternalService, "mapped")
307 .with_detail("status", response.status.as_u16().to_string())
308 .with_detail(
309 "request_id",
310 response.header("X-Request-Id").cloned().unwrap_or_default(),
311 )
312 .with_detail("body", response.body)
313 })
314 .unwrap_err();
315
316 assert_eq!(
317 error
318 .details()
319 .get("request_id")
320 .and_then(serde_json::Value::as_str),
321 Some("abc")
322 );
323 assert_eq!(
324 error
325 .details()
326 .get("body")
327 .and_then(serde_json::Value::as_str),
328 Some("upstream unavailable")
329 );
330 }
331
332 #[test]
333 fn checked_json_with_uses_custom_error_mapper() {
334 let resp = Response::new(
335 StatusCode::IM_A_TEAPOT,
336 HashMap::new(),
337 Bytes::from("short and stout"),
338 );
339
340 let error = resp
341 .checked_json_with::<serde_json::Value, _>(|response| {
342 AppError::new(
343 ErrorCode::ExternalService,
344 format!("mapped {}", response.status.as_u16()),
345 )
346 .with_detail("body", response.body)
347 })
348 .expect_err("teapot response should be mapped");
349
350 assert!(error.message().contains("mapped 418"));
351 assert_eq!(
352 error
353 .details()
354 .get("body")
355 .and_then(serde_json::Value::as_str),
356 Some("short and stout")
357 );
358 }
359
360 #[test]
361 fn default_error_mapper_covers_client_auth_and_server_statuses() {
362 let cases = [
363 (StatusCode::BAD_REQUEST, ErrorCode::InvalidInput),
364 (StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
365 (StatusCode::FORBIDDEN, ErrorCode::Forbidden),
366 (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal),
367 (StatusCode::BAD_GATEWAY, ErrorCode::Internal),
368 (StatusCode::SERVICE_UNAVAILABLE, ErrorCode::Internal),
369 (StatusCode::GATEWAY_TIMEOUT, ErrorCode::Internal),
370 (StatusCode::IM_A_TEAPOT, ErrorCode::ExternalService),
371 ];
372
373 for (status, code) in cases {
374 let error = Response::new(status, HashMap::new(), Bytes::from("body"))
375 .error_for_status()
376 .expect_err("non-success status should fail");
377 assert_eq!(error.code(), code, "{status}");
378 assert_eq!(
379 error
380 .details()
381 .get("status")
382 .and_then(serde_json::Value::as_str),
383 Some(status.as_u16().to_string().as_str())
384 );
385 }
386 }
387}