use http::{HeaderMap, StatusCode};
use soaprs_core::{DiagnosticId, SoapError, SoapErrorKind};
pub const fn default_error_status(error: &SoapError) -> StatusCode {
match error.kind() {
SoapErrorKind::NotFound => StatusCode::NOT_FOUND,
SoapErrorKind::Validation | SoapErrorKind::Domain => StatusCode::UNPROCESSABLE_ENTITY,
SoapErrorKind::Conflict => StatusCode::CONFLICT,
SoapErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
SoapErrorKind::Forbidden => StatusCode::FORBIDDEN,
SoapErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
SoapErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
SoapErrorKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
SoapErrorKind::Infrastructure => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub const fn default_error_code(kind: SoapErrorKind) -> &'static str {
match kind {
SoapErrorKind::NotFound => "not_found",
SoapErrorKind::Validation => "validation_error",
SoapErrorKind::Conflict => "conflict",
SoapErrorKind::Unauthorized => "unauthorized",
SoapErrorKind::Forbidden => "forbidden",
SoapErrorKind::Domain => "domain_error",
SoapErrorKind::Unsupported => "unsupported",
SoapErrorKind::Timeout => "timeout",
SoapErrorKind::Unavailable => "unavailable",
SoapErrorKind::Infrastructure => "internal_error",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpErrorBody {
pub code: String,
pub message: String,
pub diagnostic_id: Option<String>,
}
#[derive(Clone)]
pub struct HttpErrorResponse {
pub status: StatusCode,
pub body: HttpErrorBody,
pub headers: HeaderMap,
}
impl std::fmt::Debug for HttpErrorResponse {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HttpErrorResponse")
.field("status", &self.status)
.field("body", &self.body)
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
pub trait HttpErrorMapper: Send + Sync {
fn map_error(&self, error: &SoapError) -> HttpErrorResponse;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultHttpErrorMapper;
impl HttpErrorMapper for DefaultHttpErrorMapper {
fn map_error(&self, error: &SoapError) -> HttpErrorResponse {
default_error_response(error)
}
}
pub fn default_error_response(error: &SoapError) -> HttpErrorResponse {
HttpErrorResponse {
status: default_error_status(error),
body: HttpErrorBody {
code: default_error_code(error.kind()).to_owned(),
message: error.message().to_owned(),
diagnostic_id: error
.diagnostic_id()
.map(DiagnosticId::as_str)
.map(str::to_owned),
},
headers: HeaderMap::new(),
}
}
#[cfg(test)]
mod tests {
use http::{HeaderValue, StatusCode, header::WWW_AUTHENTICATE};
use soaprs_core::SoapError;
use super::{default_error_response, default_error_status};
#[test]
fn distinguishes_authentication_and_authorization() {
assert_eq!(
default_error_status(&SoapError::unauthorized()),
StatusCode::UNAUTHORIZED
);
assert_eq!(
default_error_status(&SoapError::forbidden()),
StatusCode::FORBIDDEN
);
}
#[test]
fn maps_stable_error_categories_to_default_statuses() {
let cases = [
(SoapError::not_found("user"), StatusCode::NOT_FOUND),
(
SoapError::validation("invalid email"),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
SoapError::domain("account is closed"),
StatusCode::UNPROCESSABLE_ENTITY,
),
(SoapError::conflict("email exists"), StatusCode::CONFLICT),
(
SoapError::unsupported("full-text search"),
StatusCode::NOT_IMPLEMENTED,
),
(
SoapError::timeout("database query"),
StatusCode::GATEWAY_TIMEOUT,
),
(
SoapError::unavailable("database"),
StatusCode::SERVICE_UNAVAILABLE,
),
(
SoapError::infrastructure("database operation"),
StatusCode::INTERNAL_SERVER_ERROR,
),
];
for (error, expected) in cases {
assert_eq!(default_error_status(&error), expected);
}
}
#[test]
fn safe_error_body_contains_stable_code_and_diagnostic_id() {
let error = SoapError::infrastructure("request failed")
.with_diagnostic_id("diagnostic-42")
.with_source(std::io::Error::other("secret driver detail"));
let response = default_error_response(&error);
assert_eq!(response.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(response.body.code, "internal_error");
assert_eq!(response.body.message, "request failed");
assert_eq!(
response.body.diagnostic_id.as_deref(),
Some("diagnostic-42")
);
assert!(!response.body.message.contains("secret driver detail"));
}
#[test]
fn error_response_debug_output_redacts_header_values() {
let mut response = default_error_response(&SoapError::unauthorized());
response.headers.insert(
WWW_AUTHENTICATE,
HeaderValue::from_static("Bearer realm=\"private-realm\""),
);
let debug = format!("{response:?}");
assert!(debug.contains("www-authenticate"));
assert!(!debug.contains("private-realm"));
}
}