use bytes::Bytes;
use http::StatusCode;
use rskit_errors::{AppError, ErrorCode};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ErrorResponse {
pub status: StatusCode,
pub headers: HashMap<String, String>,
pub body: String,
}
impl ErrorResponse {
#[must_use]
pub fn header(&self, name: &str) -> Option<&String> {
header_value(&self.headers, name)
}
}
pub struct Response {
pub status: StatusCode,
pub headers: HashMap<String, String>,
body: Bytes,
}
impl Response {
pub(crate) fn new(status: StatusCode, headers: HashMap<String, String>, body: Bytes) -> Self {
Self {
status,
headers,
body,
}
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn status_u16(&self) -> u16 {
self.status.as_u16()
}
#[must_use]
pub fn is_success(&self) -> bool {
self.status.is_success()
}
#[must_use]
pub fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
#[must_use]
pub fn header(&self, name: &str) -> Option<&String> {
header_value(&self.headers, name)
}
#[must_use]
pub fn body_bytes(&self) -> &Bytes {
&self.body
}
pub fn into_bytes(self) -> Bytes {
self.body
}
pub fn text(self) -> rskit_errors::AppResult<String> {
String::from_utf8(self.body.to_vec())
.map_err(|e| AppError::new(ErrorCode::InvalidInput, format!("invalid utf8: {}", e)))
}
#[must_use]
pub fn text_or_diagnostic(self) -> String {
String::from_utf8(self.body.to_vec()).unwrap_or_else(|_| "<non-utf8 body>".to_string())
}
pub fn json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
serde_json::from_slice(&self.body).map_err(|e| {
AppError::new(
ErrorCode::InvalidInput,
format!("failed to parse json response: {}", e),
)
})
}
pub fn checked_text(self) -> rskit_errors::AppResult<String> {
self.error_for_status()?.text()
}
pub fn checked_text_with<F>(self, mapper: F) -> rskit_errors::AppResult<String>
where
F: FnOnce(ErrorResponse) -> AppError,
{
self.error_for_status_with(mapper)?.text()
}
pub fn checked_json<T: DeserializeOwned>(self) -> rskit_errors::AppResult<T> {
self.error_for_status()?.json()
}
pub fn checked_json_with<T, F>(self, mapper: F) -> rskit_errors::AppResult<T>
where
T: DeserializeOwned,
F: FnOnce(ErrorResponse) -> AppError,
{
self.error_for_status_with(mapper)?.json()
}
pub fn error_for_status(self) -> rskit_errors::AppResult<Self> {
self.error_for_status_with(default_error_mapper)
}
pub fn error_for_status_with<F>(self, mapper: F) -> rskit_errors::AppResult<Self>
where
F: FnOnce(ErrorResponse) -> AppError,
{
if self.status.is_success() {
Ok(self)
} else {
let status = self.status;
let headers = self.headers;
let body = String::from_utf8(self.body.to_vec())
.unwrap_or_else(|_| "<non-utf8 body>".to_string());
Err(mapper(ErrorResponse {
status,
headers,
body,
}))
}
}
}
fn header_value<'a>(headers: &'a HashMap<String, String>, name: &str) -> Option<&'a String> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value)
}
fn default_error_mapper(response: ErrorResponse) -> AppError {
let status = response.status;
let code = match status.as_u16() {
400 => ErrorCode::InvalidInput,
401 => ErrorCode::Unauthorized,
403 => ErrorCode::Forbidden,
404 => ErrorCode::NotFound,
409 => ErrorCode::Conflict,
429 => ErrorCode::RateLimited,
500 | 502 | 503 | 504 => ErrorCode::Internal,
_ => ErrorCode::ExternalService,
};
AppError::new(
code,
format!(
"http error: {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
),
)
.with_detail("status", status.as_u16().to_string())
.with_detail("body", response.body)
}
impl std::fmt::Debug for Response {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Response")
.field("status", &self.status)
.field("headers", &self.headers)
.field("body_len", &self.body.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_status_check() {
let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from("ok"));
assert!(resp.is_success());
let resp = Response::new(
StatusCode::NOT_FOUND,
HashMap::new(),
Bytes::from("not found"),
);
assert!(!resp.is_success());
}
#[test]
fn test_response_header_case_insensitive() {
let mut headers = HashMap::new();
headers.insert("Content-Type".to_string(), "application/json".to_string());
let resp = Response::new(StatusCode::OK, headers, Bytes::new());
assert_eq!(
resp.header("content-type"),
Some(&"application/json".to_string())
);
}
#[test]
fn body_accessors_and_debug_do_not_expose_body_contents() {
let resp = Response::new(
StatusCode::CREATED,
HashMap::from([("x-id".to_string(), "123".to_string())]),
Bytes::from_static(b"created"),
);
assert_eq!(resp.body_bytes(), &Bytes::from_static(b"created"));
let debug = format!("{resp:?}");
assert!(debug.contains("body_len"));
assert!(!debug.contains("created"));
assert_eq!(resp.into_bytes(), Bytes::from_static(b"created"));
}
#[test]
fn test_response_json() {
let json_data = r#"{"key":"value"}"#;
let resp = Response::new(StatusCode::OK, HashMap::new(), Bytes::from(json_data));
let parsed: serde_json::Value = resp.json().unwrap();
assert_eq!(parsed["key"], "value");
}
#[test]
fn checked_text_rejects_error_status_with_body_detail() {
let resp = Response::new(
StatusCode::TOO_MANY_REQUESTS,
HashMap::new(),
Bytes::from("slow down"),
);
let error = resp.checked_text().unwrap_err();
assert!(error.to_string().contains("http error: 429"));
assert_eq!(
error
.details()
.get("body")
.and_then(serde_json::Value::as_str),
Some("slow down")
);
}
#[test]
fn text_or_diagnostic_handles_non_utf8_body() {
let resp = Response::new(
StatusCode::BAD_GATEWAY,
HashMap::new(),
Bytes::from(vec![0xff]),
);
assert_eq!(resp.text_or_diagnostic(), "<non-utf8 body>");
}
#[test]
fn custom_status_mapper_receives_status_headers_and_body() {
let mut headers = HashMap::new();
headers.insert("x-request-id".to_string(), "abc".to_string());
let resp = Response::new(
StatusCode::BAD_GATEWAY,
headers,
Bytes::from("upstream unavailable"),
);
let error = resp
.error_for_status_with(|response| {
AppError::new(ErrorCode::ExternalService, "mapped")
.with_detail("status", response.status.as_u16().to_string())
.with_detail(
"request_id",
response.header("X-Request-Id").cloned().unwrap_or_default(),
)
.with_detail("body", response.body)
})
.unwrap_err();
assert_eq!(
error
.details()
.get("request_id")
.and_then(serde_json::Value::as_str),
Some("abc")
);
assert_eq!(
error
.details()
.get("body")
.and_then(serde_json::Value::as_str),
Some("upstream unavailable")
);
}
#[test]
fn checked_json_with_uses_custom_error_mapper() {
let resp = Response::new(
StatusCode::IM_A_TEAPOT,
HashMap::new(),
Bytes::from("short and stout"),
);
let error = resp
.checked_json_with::<serde_json::Value, _>(|response| {
AppError::new(
ErrorCode::ExternalService,
format!("mapped {}", response.status.as_u16()),
)
.with_detail("body", response.body)
})
.expect_err("teapot response should be mapped");
assert!(error.message().contains("mapped 418"));
assert_eq!(
error
.details()
.get("body")
.and_then(serde_json::Value::as_str),
Some("short and stout")
);
}
#[test]
fn default_error_mapper_covers_client_auth_and_server_statuses() {
let cases = [
(StatusCode::BAD_REQUEST, ErrorCode::InvalidInput),
(StatusCode::UNAUTHORIZED, ErrorCode::Unauthorized),
(StatusCode::FORBIDDEN, ErrorCode::Forbidden),
(StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal),
(StatusCode::BAD_GATEWAY, ErrorCode::Internal),
(StatusCode::SERVICE_UNAVAILABLE, ErrorCode::Internal),
(StatusCode::GATEWAY_TIMEOUT, ErrorCode::Internal),
(StatusCode::IM_A_TEAPOT, ErrorCode::ExternalService),
];
for (status, code) in cases {
let error = Response::new(status, HashMap::new(), Bytes::from("body"))
.error_for_status()
.expect_err("non-success status should fail");
assert_eq!(error.code(), code, "{status}");
assert_eq!(
error
.details()
.get("status")
.and_then(serde_json::Value::as_str),
Some(status.as_u16().to_string().as_str())
);
}
}
}