Skip to main content

rskit_httpclient/
response.rs

1//! HTTP response wrapper.
2
3use bytes::Bytes;
4use http::StatusCode;
5use rskit_errors::{AppError, ErrorCode};
6use serde::de::DeserializeOwned;
7use std::collections::HashMap;
8
9/// Non-success HTTP response details for service-specific error mapping.
10#[derive(Debug, Clone)]
11pub struct ErrorResponse {
12    /// Response status code.
13    pub status: StatusCode,
14    /// Raw response headers.
15    ///
16    /// Header names are case-insensitive; use [`ErrorResponse::header`] for lookups.
17    pub headers: HashMap<String, String>,
18    /// Response body as UTF-8 text, or a diagnostic marker for non-UTF-8 bodies.
19    pub body: String,
20}
21
22impl ErrorResponse {
23    /// Gets a response header value by name (case-insensitive).
24    #[must_use]
25    pub fn header(&self, name: &str) -> Option<&String> {
26        header_value(&self.headers, name)
27    }
28}
29
30/// Wrapped HTTP response with convenience methods.
31pub struct Response {
32    /// Response status code
33    pub status: StatusCode,
34
35    /// Response headers
36    pub headers: HashMap<String, String>,
37
38    /// Response body bytes
39    body: Bytes,
40}
41
42impl Response {
43    /// Creates a new response.
44    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    /// Gets the response status code.
53    #[must_use]
54    pub fn status(&self) -> StatusCode {
55        self.status
56    }
57
58    /// Gets the numeric response status code.
59    #[must_use]
60    pub fn status_u16(&self) -> u16 {
61        self.status.as_u16()
62    }
63
64    /// Checks if the response status is successful (2xx).
65    #[must_use]
66    pub fn is_success(&self) -> bool {
67        self.status.is_success()
68    }
69
70    /// Gets the response headers.
71    #[must_use]
72    pub fn headers(&self) -> &HashMap<String, String> {
73        &self.headers
74    }
75
76    /// Gets a header value by name (case-insensitive).
77    #[must_use]
78    pub fn header(&self, name: &str) -> Option<&String> {
79        header_value(&self.headers, name)
80    }
81
82    /// Gets the response body as a byte slice.
83    #[must_use]
84    pub fn body_bytes(&self) -> &Bytes {
85        &self.body
86    }
87
88    /// Consumes the response and returns the body as bytes.
89    pub fn into_bytes(self) -> Bytes {
90        self.body
91    }
92
93    /// Converts the response body to a string.
94    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    /// Converts the response body to a string, returning a diagnostic marker for non-UTF-8 bodies.
100    #[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    /// Parses the response body as JSON.
106    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    /// Returns the body text after converting non-2xx responses into an [`AppError`].
116    pub fn checked_text(self) -> rskit_errors::AppResult<String> {
117        self.error_for_status()?.text()
118    }
119
120    /// Returns the body text after applying custom non-2xx response mapping.
121    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    /// Returns the parsed JSON body after converting non-2xx responses into an [`AppError`].
129    pub fn checked_json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
130        self.error_for_status()?.json()
131    }
132
133    /// Returns the parsed JSON body after applying custom non-2xx response mapping.
134    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    /// Returns an error if the status is not 2xx.
143    pub fn error_for_status(self) -> rskit_errors::AppResult<Self> {
144        self.error_for_status_with(default_error_mapper)
145    }
146
147    /// Returns an error if the status is not 2xx, using the supplied response mapper.
148    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}