use crate::{Error, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
static SENSITIVE_PATTERNS: LazyLock<Vec<(regex::Regex, &'static str)>> = LazyLock::new(|| {
let patterns: [(&str, &str); 9] = [
(r"password[=:]\s*\S+", "password=[FILTERED]"),
(r"pwd[=:]\s*\S+", "pwd=[FILTERED]"),
(r"api[_-]?key[=:]\s*\S+", "api_key=[FILTERED]"),
(r"apikey[=:]\s*\S+", "apikey=[FILTERED]"),
(r"token[=:]\s*\S+", "token=[FILTERED]"),
(r"bearer\s+\S+", "Bearer [FILTERED]"),
(r"secret[=:]\s*\S+", "secret=[FILTERED]"),
(
r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
"[CARD FILTERED]",
),
(r"\b\d{3}[- ]?\d{2}[- ]?\d{4}\b", "[SSN FILTERED]"),
];
patterns
.into_iter()
.filter_map(|(pattern, replacement)| {
regex::Regex::new(&format!("(?i){pattern}"))
.ok()
.map(|re| (re, replacement))
})
.collect()
});
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResponseFormat {
#[default]
Json,
PlainText,
Html,
ProblemDetails,
JsonApi,
GraphQL,
Google,
Aws,
Azure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub metadata: HashMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub validation_errors: Vec<ValidationError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_trace: Option<String>,
}
impl ErrorResponse {
pub fn new(status: u16) -> Self {
Self {
status,
code: None,
message: String::new(),
details: None,
error_type: None,
path: None,
timestamp: Some(httpdate::fmt_http_date(std::time::SystemTime::now())),
request_id: None,
metadata: HashMap::new(),
validation_errors: Vec::new(),
stack_trace: None,
}
}
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
pub fn code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
pub fn details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
pub fn error_type(mut self, error_type: impl Into<String>) -> Self {
self.error_type = Some(error_type.into());
self
}
pub fn path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
if let Ok(json_value) = serde_json::to_value(value) {
self.metadata.insert(key.into(), json_value);
}
self
}
pub fn with_validation_error(mut self, error: ValidationError) -> Self {
self.validation_errors.push(error);
self
}
pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
self.validation_errors.extend(errors);
self
}
pub fn stack_trace(mut self, trace: impl Into<String>) -> Self {
self.stack_trace = Some(trace.into());
self
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| {
format!(
r#"{{"status":{},"message":"{}"}}"#,
self.status, self.message
)
})
}
pub fn to_plain_text(&self) -> String {
let mut text = format!("Error {}: {}", self.status, self.message);
if let Some(ref details) = self.details {
text.push_str(&format!("\nDetails: {}", details));
}
if !self.validation_errors.is_empty() {
text.push_str("\nValidation Errors:");
for err in &self.validation_errors {
text.push_str(&format!("\n - {}: {}", err.field, err.message));
}
}
text
}
pub fn to_html(&self) -> String {
let mut html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Error {}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px; background: #f5f5f5; }}
.error {{ background: white; border-radius: 8px; padding: 24px; max-width: 600px; margin: 0 auto; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
h1 {{ color: #e53935; margin-top: 0; }}
.status {{ color: #666; font-size: 14px; }}
.details {{ background: #f5f5f5; padding: 12px; border-radius: 4px; margin-top: 16px; }}
.validation {{ margin-top: 16px; }}
.validation li {{ color: #d32f2f; }}
</style>
</head>
<body>
<div class="error">
<p class="status">Error {}</p>
<h1>{}</h1>"#,
self.status,
self.status,
html_escape(&self.message)
);
if let Some(ref details) = self.details {
html.push_str(&format!(
r#"<div class="details">{}</div>"#,
html_escape(details)
));
}
if !self.validation_errors.is_empty() {
html.push_str(r#"<div class="validation"><h3>Validation Errors</h3><ul>"#);
for err in &self.validation_errors {
html.push_str(&format!(
"<li><strong>{}</strong>: {}</li>",
html_escape(&err.field),
html_escape(&err.message)
));
}
html.push_str("</ul></div>");
}
html.push_str("</div></body></html>");
html
}
pub fn to_problem_details(&self) -> String {
let problem = ProblemDetails {
type_uri: self
.error_type
.clone()
.unwrap_or_else(|| "about:blank".to_string()),
title: self.message.clone(),
status: self.status,
detail: self.details.clone(),
instance: self.path.clone(),
extensions: self.metadata.clone(),
};
serde_json::to_string_pretty(&problem).unwrap_or_else(|_| self.to_json())
}
pub fn to_json_api(&self) -> String {
let error = JsonApiError {
id: self.request_id.clone(),
status: self.status.to_string(),
code: self.code.clone(),
title: Some(self.message.clone()),
detail: self.details.clone(),
source: if !self.validation_errors.is_empty() {
Some(JsonApiErrorSource {
pointer: self
.validation_errors
.first()
.map(|e| format!("/data/attributes/{}", e.field)),
parameter: None,
header: None,
})
} else {
None
},
meta: if self.metadata.is_empty() {
None
} else {
Some(self.metadata.clone())
},
};
let response = JsonApiErrorResponse {
errors: std::iter::once(error)
.chain(self.validation_errors.iter().skip(1).map(|v| JsonApiError {
id: None,
status: self.status.to_string(),
code: self.code.clone(),
title: Some(v.message.clone()),
detail: v.rule.clone(),
source: Some(JsonApiErrorSource {
pointer: Some(format!("/data/attributes/{}", v.field)),
parameter: None,
header: None,
}),
meta: None,
}))
.collect(),
};
serde_json::to_string_pretty(&response).unwrap_or_else(|_| self.to_json())
}
pub fn to_graphql(&self) -> String {
let errors: Vec<GraphQLError> = if self.validation_errors.is_empty() {
vec![GraphQLError {
message: self.message.clone(),
locations: None,
path: self
.path
.as_ref()
.map(|p| vec![serde_json::Value::String(p.clone())]),
extensions: Some(GraphQLErrorExtensions {
code: self.code.clone().or(self.error_type.clone()),
status: Some(self.status),
timestamp: self.timestamp.clone(),
details: self.details.clone(),
}),
}]
} else {
self.validation_errors
.iter()
.map(|v| GraphQLError {
message: v.message.clone(),
locations: None,
path: Some(vec![serde_json::Value::String(v.field.clone())]),
extensions: Some(GraphQLErrorExtensions {
code: v.rule.clone(),
status: Some(self.status),
timestamp: None,
details: None,
}),
})
.collect()
};
let response = GraphQLErrorResponse { data: None, errors };
serde_json::to_string_pretty(&response).unwrap_or_else(|_| self.to_json())
}
pub fn to_google(&self) -> String {
let error = GoogleError {
error: GoogleErrorBody {
code: self.status,
message: self.message.clone(),
status: self
.error_type
.clone()
.unwrap_or_else(|| google_status_from_http(self.status)),
details: self
.validation_errors
.iter()
.map(|v| GoogleErrorDetail {
type_url: "type.googleapis.com/google.rpc.BadRequest.FieldViolation"
.to_string(),
field: v.field.clone(),
description: v.message.clone(),
})
.collect(),
},
};
serde_json::to_string_pretty(&error).unwrap_or_else(|_| self.to_json())
}
pub fn to_aws(&self) -> String {
let error = AwsError {
__type: self
.error_type
.clone()
.unwrap_or_else(|| format!("{}Exception", aws_error_type(self.status))),
message: self.message.clone(),
code: self.code.clone(),
request_id: self.request_id.clone(),
};
serde_json::to_string_pretty(&error).unwrap_or_else(|_| self.to_json())
}
pub fn to_azure(&self) -> String {
let inner_errors: Vec<AzureInnerError> = self
.validation_errors
.iter()
.map(|v| AzureInnerError {
code: v
.rule
.clone()
.unwrap_or_else(|| "ValidationError".to_string()),
message: v.message.clone(),
target: Some(v.field.clone()),
})
.collect();
let error = AzureError {
error: AzureErrorBody {
code: self.code.clone().unwrap_or_else(|| {
self.error_type
.clone()
.unwrap_or_else(|| "Error".to_string())
}),
message: self.message.clone(),
target: self.path.clone(),
details: if inner_errors.is_empty() {
None
} else {
Some(inner_errors)
},
innererror: self.details.as_ref().map(|d| AzureInnerErrorInfo {
code: self.error_type.clone(),
message: Some(d.clone()),
}),
},
};
serde_json::to_string_pretty(&error).unwrap_or_else(|_| self.to_json())
}
pub fn into_http_response(self, format: ResponseFormat) -> HttpResponse {
let (body, content_type) = match format {
ResponseFormat::Json => (self.to_json(), "application/json"),
ResponseFormat::PlainText => (self.to_plain_text(), "text/plain; charset=utf-8"),
ResponseFormat::Html => (self.to_html(), "text/html; charset=utf-8"),
ResponseFormat::ProblemDetails => {
(self.to_problem_details(), "application/problem+json")
}
ResponseFormat::JsonApi => (self.to_json_api(), "application/vnd.api+json"),
ResponseFormat::GraphQL => (self.to_graphql(), "application/json"),
ResponseFormat::Google => (self.to_google(), "application/json"),
ResponseFormat::Aws => (self.to_aws(), "application/x-amz-json-1.1"),
ResponseFormat::Azure => (self.to_azure(), "application/json"),
};
HttpResponse::new(self.status)
.with_header("Content-Type".to_string(), content_type.to_string())
.with_body(body.into_bytes())
}
}
impl Default for ErrorResponse {
fn default() -> Self {
Self::new(500).message("Internal Server Error")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
}
impl ValidationError {
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
rule: None,
value: None,
}
}
pub fn rule(mut self, rule: impl Into<String>) -> Self {
self.rule = Some(rule.into());
self
}
pub fn value(mut self, value: impl Serialize) -> Self {
self.value = serde_json::to_value(value).ok();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(flatten)]
pub extensions: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonApiErrorResponse {
pub errors: Vec<JsonApiError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonApiError {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<JsonApiErrorSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonApiErrorSource {
#[serde(skip_serializing_if = "Option::is_none")]
pub pointer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLErrorResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
pub errors: Vec<GraphQLError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLError {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub locations: Option<Vec<GraphQLErrorLocation>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<GraphQLErrorExtensions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLErrorLocation {
pub line: u32,
pub column: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLErrorExtensions {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleError {
pub error: GoogleErrorBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleErrorBody {
pub code: u16,
pub message: String,
pub status: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub details: Vec<GoogleErrorDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoogleErrorDetail {
#[serde(rename = "@type")]
pub type_url: String,
pub field: String,
pub description: String,
}
fn google_status_from_http(status: u16) -> String {
match status {
400 => "INVALID_ARGUMENT",
401 => "UNAUTHENTICATED",
403 => "PERMISSION_DENIED",
404 => "NOT_FOUND",
409 => "ALREADY_EXISTS",
429 => "RESOURCE_EXHAUSTED",
499 => "CANCELLED",
500 => "INTERNAL",
501 => "UNIMPLEMENTED",
503 => "UNAVAILABLE",
504 => "DEADLINE_EXCEEDED",
_ => "UNKNOWN",
}
.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AwsError {
#[serde(rename = "__type")]
pub __type: String,
pub message: String,
#[serde(rename = "Code", skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(rename = "RequestId", skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
fn aws_error_type(status: u16) -> String {
match status {
400 => "Validation",
401 => "UnauthorizedAccess",
403 => "AccessDenied",
404 => "ResourceNotFound",
409 => "Conflict",
429 => "Throttling",
500 => "InternalService",
503 => "ServiceUnavailable",
_ => "Service",
}
.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureError {
pub error: AzureErrorBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureErrorBody {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Vec<AzureInnerError>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub innererror: Option<AzureInnerErrorInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureInnerError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureInnerErrorInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ErrorContext {
pub request: HttpRequest,
pub request_id: Option<String>,
pub user_id: Option<String>,
pub data: HashMap<String, serde_json::Value>,
}
impl ErrorContext {
pub fn from_request(request: HttpRequest) -> Self {
let request_id = request
.headers
.get("x-request-id")
.or_else(|| request.headers.get("X-Request-Id"))
.cloned();
Self {
request,
request_id,
user_id: None,
data: HashMap::new(),
}
}
pub fn user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn with_data(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
if let Ok(json_value) = serde_json::to_value(value) {
self.data.insert(key.into(), json_value);
}
self
}
}
pub type TransformerFn = Arc<dyn Fn(&Error, &ErrorContext) -> Option<ErrorResponse> + Send + Sync>;
pub type FilterFn = Arc<dyn Fn(&Error) -> Error + Send + Sync>;
pub type LoggerFn = Arc<dyn Fn(&Error, &ErrorContext, &ErrorResponse) + Send + Sync>;
pub struct ErrorTransformer {
format: ResponseFormat,
include_stack_trace: bool,
filter_sensitive: bool,
transformers: Vec<TransformerFn>,
filters: Vec<FilterFn>,
loggers: Vec<LoggerFn>,
include_path: bool,
include_timestamp: bool,
error_codes: HashMap<String, String>,
production_mode: bool,
}
impl ErrorTransformer {
pub fn new() -> Self {
Self {
format: ResponseFormat::Json,
include_stack_trace: false,
filter_sensitive: true,
transformers: Vec::new(),
filters: Vec::new(),
loggers: Vec::new(),
include_path: true,
include_timestamp: true,
error_codes: HashMap::new(),
production_mode: true,
}
}
pub fn format(mut self, format: ResponseFormat) -> Self {
self.format = format;
self
}
pub fn include_stack_trace(mut self, include: bool) -> Self {
self.include_stack_trace = include;
self
}
pub fn filter_sensitive_data(mut self, filter: bool) -> Self {
self.filter_sensitive = filter;
self
}
pub fn include_path(mut self, include: bool) -> Self {
self.include_path = include;
self
}
pub fn include_timestamp(mut self, include: bool) -> Self {
self.include_timestamp = include;
self
}
pub fn production_mode(mut self, production: bool) -> Self {
self.production_mode = production;
self
}
pub fn with_transformer<F>(mut self, transformer: F) -> Self
where
F: Fn(&Error, &ErrorContext) -> Option<ErrorResponse> + Send + Sync + 'static,
{
self.transformers.push(Arc::new(transformer));
self
}
pub fn with_filter<F>(mut self, filter: F) -> Self
where
F: Fn(&Error) -> Error + Send + Sync + 'static,
{
self.filters.push(Arc::new(filter));
self
}
pub fn with_logger<F>(mut self, logger: F) -> Self
where
F: Fn(&Error, &ErrorContext, &ErrorResponse) + Send + Sync + 'static,
{
self.loggers.push(Arc::new(logger));
self
}
pub fn map_error_code(
mut self,
error_type: impl Into<String>,
code: impl Into<String>,
) -> Self {
self.error_codes.insert(error_type.into(), code.into());
self
}
pub fn transform(&self, error: &Error, request: &HttpRequest) -> HttpResponse {
let context = ErrorContext::from_request(request.clone());
self.transform_with_context(error, &context)
}
pub fn transform_with_context(&self, error: &Error, context: &ErrorContext) -> HttpResponse {
let filtered_error = self.apply_filters(error);
for transformer in &self.transformers {
if let Some(response) = transformer(&filtered_error, context) {
return self.finalize_response(response, &filtered_error, context);
}
}
let response = self.default_transform(&filtered_error, context);
self.finalize_response(response, &filtered_error, context)
}
fn apply_filters(&self, error: &Error) -> Error {
let mut current = if self.filter_sensitive {
self.filter_error_message(error)
} else {
self.clone_error(error)
};
for filter in &self.filters {
current = filter(¤t);
}
current
}
fn clone_error(&self, error: &Error) -> Error {
map_error_message(error, |msg| msg.to_string())
}
fn filter_error_message(&self, error: &Error) -> Error {
map_error_message(error, |msg| self.filter_sensitive_string(msg))
}
fn filter_sensitive_string(&self, s: &str) -> String {
let mut result = s.to_string();
for (regex, replacement) in SENSITIVE_PATTERNS.iter() {
result = regex.replace_all(&result, *replacement).to_string();
}
result
}
fn default_transform(&self, error: &Error, context: &ErrorContext) -> ErrorResponse {
let status = error.status_code();
let error_type = self.get_error_type(error);
let mut response = ErrorResponse::new(status).error_type(&error_type);
if self.production_mode && error.is_server_error() {
response = response.message("An internal server error occurred");
} else {
response = response.message(error.to_string());
}
if let Some(code) = self.error_codes.get(&error_type) {
response = response.code(code);
}
if self.include_path {
response = response.path(&context.request.path);
}
if let Some(ref request_id) = context.request_id {
response = response.request_id(request_id);
}
if !self.include_timestamp {
response.timestamp = None;
}
response
}
fn get_error_type(&self, error: &Error) -> String {
match error {
Error::BadRequest(_) => "BAD_REQUEST".to_string(),
Error::Unauthorized(_) => "UNAUTHORIZED".to_string(),
Error::Forbidden(_) => "FORBIDDEN".to_string(),
Error::NotFound(_) => "NOT_FOUND".to_string(),
Error::Validation(_) => "VALIDATION_ERROR".to_string(),
Error::Internal(_) => "INTERNAL_ERROR".to_string(),
Error::Conflict(_) => "CONFLICT".to_string(),
Error::TooManyRequests(_) => "RATE_LIMITED".to_string(),
Error::ServiceUnavailable(_) => "SERVICE_UNAVAILABLE".to_string(),
Error::RequestTimeout(_) => "TIMEOUT".to_string(),
_ => "ERROR".to_string(),
}
}
fn finalize_response(
&self,
response: ErrorResponse,
error: &Error,
context: &ErrorContext,
) -> HttpResponse {
let mut response = response;
if self.include_stack_trace && response.stack_trace.is_none() {
response = response.stack_trace(std::backtrace::Backtrace::force_capture().to_string());
}
for logger in &self.loggers {
logger(error, context, &response);
}
response.into_http_response(self.format)
}
}
impl Default for ErrorTransformer {
fn default() -> Self {
Self::new()
}
}
impl Clone for ErrorTransformer {
fn clone(&self) -> Self {
Self {
format: self.format,
include_stack_trace: self.include_stack_trace,
filter_sensitive: self.filter_sensitive,
transformers: self.transformers.clone(),
filters: self.filters.clone(),
loggers: self.loggers.clone(),
include_path: self.include_path,
include_timestamp: self.include_timestamp,
error_codes: self.error_codes.clone(),
production_mode: self.production_mode,
}
}
}
impl ErrorTransformer {
pub fn development() -> Self {
Self::new()
.production_mode(false)
.include_stack_trace(true)
.filter_sensitive_data(false)
.with_logger(|error, ctx, _response| {
eprintln!(
"[DEV ERROR] {} {} - {:?}",
ctx.request.method, ctx.request.path, error
);
})
}
pub fn production() -> Self {
Self::new()
.production_mode(true)
.include_stack_trace(false)
.filter_sensitive_data(true)
.with_logger(|error, ctx, response| {
if error.is_server_error() {
crate::logging::error!(
status = response.status,
path = ctx.request.path,
request_id = ctx.request_id,
error = %error,
"Server error occurred"
);
} else {
crate::logging::warn!(
status = response.status,
path = ctx.request.path,
request_id = ctx.request_id,
"Client error occurred"
);
}
})
}
pub fn api() -> Self {
Self::new()
.format(ResponseFormat::ProblemDetails)
.production_mode(true)
.filter_sensitive_data(true)
}
}
pub struct ErrorResponseBuilder {
transformer: ErrorTransformer,
}
impl ErrorResponseBuilder {
pub fn new() -> Self {
Self {
transformer: ErrorTransformer::new(),
}
}
pub fn with_transformer(mut self, transformer: ErrorTransformer) -> Self {
self.transformer = transformer;
self
}
pub fn bad_request(message: impl Into<String>) -> HttpResponse {
let error = Error::BadRequest(message.into());
ErrorTransformer::new().transform(&error, &HttpRequest::new("".into(), "".into()))
}
pub fn unauthorized(message: impl Into<String>) -> HttpResponse {
let error = Error::Unauthorized(message.into());
ErrorTransformer::new().transform(&error, &HttpRequest::new("".into(), "".into()))
}
pub fn forbidden(message: impl Into<String>) -> HttpResponse {
let error = Error::Forbidden(message.into());
ErrorTransformer::new().transform(&error, &HttpRequest::new("".into(), "".into()))
}
pub fn not_found(message: impl Into<String>) -> HttpResponse {
let error = Error::NotFound(message.into());
ErrorTransformer::new().transform(&error, &HttpRequest::new("".into(), "".into()))
}
pub fn internal_error(message: impl Into<String>) -> HttpResponse {
let error = Error::Internal(message.into());
ErrorTransformer::new().transform(&error, &HttpRequest::new("".into(), "".into()))
}
pub fn validation_error(errors: Vec<ValidationError>) -> HttpResponse {
let response = ErrorResponse::new(422)
.message("Validation failed")
.error_type("VALIDATION_ERROR")
.with_validation_errors(errors);
response.into_http_response(ResponseFormat::Json)
}
}
impl Default for ErrorResponseBuilder {
fn default() -> Self {
Self::new()
}
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn map_error_message(error: &Error, f: impl FnOnce(&str) -> String) -> Error {
macro_rules! map_variants {
($($variant:ident),* $(,)?) => {
match error {
$(Error::$variant(msg) => Error::$variant(f(msg)),)*
other => Error::Internal(f(&other.to_string())),
}
};
}
map_variants!(
Http,
RouteNotFound,
MethodNotAllowed,
DependencyInjection,
ProviderNotFound,
Serialization,
Deserialization,
Validation,
Internal,
Forbidden,
BadRequest,
Unauthorized,
PaymentRequired,
NotFound,
NotAcceptable,
ProxyAuthenticationRequired,
RequestTimeout,
Conflict,
Gone,
LengthRequired,
PreconditionFailed,
PayloadTooLarge,
UriTooLong,
UnsupportedMediaType,
RangeNotSatisfiable,
ExpectationFailed,
ImATeapot,
MisdirectedRequest,
UnprocessableEntity,
Locked,
FailedDependency,
TooEarly,
UpgradeRequired,
PreconditionRequired,
TooManyRequests,
RequestHeaderFieldsTooLarge,
UnavailableForLegalReasons,
NotImplemented,
BadGateway,
ServiceUnavailable,
GatewayTimeout,
HttpVersionNotSupported,
VariantAlsoNegotiates,
InsufficientStorage,
LoopDetected,
NotExtended,
NetworkAuthenticationRequired,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_response_new() {
let response = ErrorResponse::new(404).message("Not found");
assert_eq!(response.status, 404);
assert_eq!(response.message, "Not found");
}
#[test]
fn test_error_response_builder() {
let response = ErrorResponse::new(400)
.message("Bad request")
.code("ERR_001")
.details("Invalid input")
.error_type("VALIDATION")
.path("/api/users")
.request_id("req-123");
assert_eq!(response.status, 400);
assert_eq!(response.code, Some("ERR_001".to_string()));
assert_eq!(response.details, Some("Invalid input".to_string()));
}
#[test]
fn test_validation_error() {
let err = ValidationError::new("email", "Invalid email format")
.rule("email")
.value("not-an-email");
assert_eq!(err.field, "email");
assert_eq!(err.message, "Invalid email format");
assert_eq!(err.rule, Some("email".to_string()));
}
#[test]
fn test_error_response_with_validation_errors() {
let response = ErrorResponse::new(422)
.message("Validation failed")
.with_validation_error(ValidationError::new("name", "Required"))
.with_validation_error(ValidationError::new("email", "Invalid"));
assert_eq!(response.validation_errors.len(), 2);
}
#[test]
fn test_error_response_to_json() {
let response = ErrorResponse::new(404).message("Not found");
let json = response.to_json();
assert!(json.contains("404"));
assert!(json.contains("Not found"));
}
#[test]
fn test_error_response_to_plain_text() {
let response = ErrorResponse::new(404).message("Resource not found");
let text = response.to_plain_text();
assert!(text.contains("404"));
assert!(text.contains("Resource not found"));
}
#[test]
fn test_error_response_to_html() {
let response = ErrorResponse::new(500).message("Server error");
let html = response.to_html();
assert!(html.contains("500"));
assert!(html.contains("Server error"));
assert!(html.contains("<html>"));
}
#[test]
fn test_error_transformer_default() {
let transformer = ErrorTransformer::new();
let error = Error::NotFound("User not found".to_string());
let request = HttpRequest::new("GET".to_string(), "/users/123".to_string());
let response = transformer.transform(&error, &request);
assert_eq!(response.status, 404);
}
#[test]
fn test_error_transformer_production_mode() {
let transformer = ErrorTransformer::new().production_mode(true);
let error = Error::Internal("Database connection failed".to_string());
let request = HttpRequest::new("GET".to_string(), "/api".to_string());
let response = transformer.transform(&error, &request);
assert_eq!(response.status, 500);
let body = String::from_utf8(response.body).unwrap();
assert!(body.contains("internal server error"));
}
#[test]
fn test_error_transformer_with_custom_transformer() {
let transformer = ErrorTransformer::new().with_transformer(|error, _ctx| {
if matches!(error, Error::NotFound(_)) {
Some(ErrorResponse::new(404).message("Custom not found message"))
} else {
None
}
});
let error = Error::NotFound("test".to_string());
let request = HttpRequest::new("GET".to_string(), "/test".to_string());
let response = transformer.transform(&error, &request);
let body = String::from_utf8(response.body).unwrap();
assert!(body.contains("Custom not found message"));
}
#[test]
fn test_sensitive_data_filtering() {
let transformer = ErrorTransformer::new().filter_sensitive_data(true);
let error = Error::BadRequest("Invalid password=secret123 in request".to_string());
let request = HttpRequest::new("POST".to_string(), "/login".to_string());
let response = transformer.transform(&error, &request);
let body = String::from_utf8(response.body).unwrap();
assert!(!body.contains("secret123"));
assert!(body.contains("[FILTERED]"));
}
#[test]
fn test_error_context() {
let mut request = HttpRequest::new("GET".to_string(), "/api".to_string());
request
.headers
.insert("X-Request-Id".to_string(), "req-456".to_string());
let context = ErrorContext::from_request(request)
.user_id("user-123")
.with_data("operation", "fetch_users");
assert_eq!(context.request_id, Some("req-456".to_string()));
assert_eq!(context.user_id, Some("user-123".to_string()));
}
#[test]
fn test_problem_details_format() {
let response = ErrorResponse::new(404)
.message("User not found")
.error_type("https://api.example.com/errors/not-found")
.path("/users/123");
let json = response.to_problem_details();
assert!(json.contains("type"));
assert!(json.contains("title"));
assert!(json.contains("status"));
}
#[test]
fn test_response_format_content_type() {
let response = ErrorResponse::new(400).message("Bad request");
let http_response = response.clone().into_http_response(ResponseFormat::Json);
assert_eq!(
http_response.headers.get("Content-Type"),
Some(&"application/json".to_string())
);
let http_response = response
.clone()
.into_http_response(ResponseFormat::PlainText);
assert_eq!(
http_response.headers.get("Content-Type"),
Some(&"text/plain; charset=utf-8".to_string())
);
let http_response = response
.clone()
.into_http_response(ResponseFormat::ProblemDetails);
assert_eq!(
http_response.headers.get("Content-Type"),
Some(&"application/problem+json".to_string())
);
let http_response = response.clone().into_http_response(ResponseFormat::JsonApi);
assert_eq!(
http_response.headers.get("Content-Type"),
Some(&"application/vnd.api+json".to_string())
);
let http_response = response.clone().into_http_response(ResponseFormat::Aws);
assert_eq!(
http_response.headers.get("Content-Type"),
Some(&"application/x-amz-json-1.1".to_string())
);
}
#[test]
fn test_json_api_format() {
let response = ErrorResponse::new(422)
.message("Validation failed")
.code("VALIDATION_ERROR")
.with_validation_error(ValidationError::new("email", "Invalid format"));
let json = response.to_json_api();
assert!(json.contains("errors"));
assert!(json.contains("422"));
assert!(json.contains("email"));
}
#[test]
fn test_graphql_format() {
let response = ErrorResponse::new(400)
.message("Bad request")
.code("BAD_REQUEST")
.path("/graphql");
let json = response.to_graphql();
assert!(json.contains("errors"));
assert!(json.contains("Bad request"));
assert!(json.contains("extensions"));
}
#[test]
fn test_google_format() {
let response = ErrorResponse::new(400)
.message("Invalid argument")
.with_validation_error(ValidationError::new("name", "Required"));
let json = response.to_google();
assert!(json.contains("error"));
assert!(json.contains("INVALID_ARGUMENT"));
assert!(json.contains("400"));
}
#[test]
fn test_aws_format() {
let response = ErrorResponse::new(400)
.message("Validation error")
.request_id("req-123");
let json = response.to_aws();
assert!(json.contains("__type"));
assert!(json.contains("ValidationException"));
assert!(json.contains("req-123"));
}
#[test]
fn test_azure_format() {
let response = ErrorResponse::new(400)
.message("Bad request")
.code("InvalidInput")
.path("/api/users")
.with_validation_error(ValidationError::new("email", "Invalid"));
let json = response.to_azure();
assert!(json.contains("error"));
assert!(json.contains("InvalidInput"));
assert!(json.contains("details"));
}
#[test]
fn test_error_code_mapping() {
let transformer = ErrorTransformer::new()
.map_error_code("NOT_FOUND", "ERR_404")
.map_error_code("VALIDATION_ERROR", "ERR_422");
assert!(transformer.error_codes.contains_key("NOT_FOUND"));
assert_eq!(
transformer.error_codes.get("NOT_FOUND"),
Some(&"ERR_404".to_string())
);
}
#[test]
fn test_preset_development() {
let transformer = ErrorTransformer::development();
assert!(!transformer.production_mode);
assert!(transformer.include_stack_trace);
}
#[test]
fn test_preset_production() {
let transformer = ErrorTransformer::production();
assert!(transformer.production_mode);
assert!(!transformer.include_stack_trace);
assert!(transformer.filter_sensitive);
}
#[test]
fn test_preset_api() {
let transformer = ErrorTransformer::api();
assert_eq!(transformer.format, ResponseFormat::ProblemDetails);
}
#[test]
fn test_transform_preserves_status_of_unlisted_variants() {
let transformer = ErrorTransformer::new();
let request = HttpRequest::new("GET".into(), "/test".into());
let response = transformer.transform(&Error::TooManyRequests("slow down".into()), &request);
assert_eq!(response.status, 429);
let response = transformer.transform(&Error::Conflict("dup".into()), &request);
assert_eq!(response.status, 409);
let response = transformer.transform(&Error::ServiceUnavailable("down".into()), &request);
assert_eq!(response.status, 503);
}
#[test]
fn test_user_registered_filters_are_applied() {
let transformer =
ErrorTransformer::new().with_filter(|_| Error::NotFound("filtered by user".into()));
let request = HttpRequest::new("GET".into(), "/test".into());
let response = transformer.transform(&Error::Internal("original".into()), &request);
assert_eq!(response.status, 404);
let body = String::from_utf8(response.into_body_bytes().to_vec()).unwrap();
assert!(body.contains("filtered by user"));
}
#[test]
fn test_to_html_escapes_message_and_details() {
let response = ErrorResponse::new(400)
.message("<script>alert(1)</script>")
.details("a & b <img>");
let html = response.to_html();
assert!(!html.contains("<script>alert(1)</script>"));
assert!(html.contains("<script>"));
assert!(html.contains("a & b <img>"));
}
#[test]
fn test_filter_sensitive_string_redacts_pii_and_secrets() {
let transformer = ErrorTransformer::new().filter_sensitive_data(true);
let input = "card 4111 1111 1111 1111 ssn 123-45-6789 \
email user@example.com token=abcdef123 password=hunter2 \
Bearer sk_live_secret";
let filtered = transformer.filter_sensitive_string(input);
assert!(filtered.contains("[CARD FILTERED]"));
assert!(!filtered.contains("4111 1111 1111 1111"));
assert!(filtered.contains("[SSN FILTERED]"));
assert!(!filtered.contains("123-45-6789"));
assert!(!filtered.contains("abcdef123"));
assert!(!filtered.contains("hunter2"));
assert!(!filtered.contains("sk_live_secret"));
assert!(filtered.contains("[FILTERED]"));
assert!(filtered.contains("user@example.com"));
}
#[test]
fn test_filter_sensitive_string_stable_across_calls() {
let transformer = ErrorTransformer::new().filter_sensitive_data(true);
let input = "password=secret123 token=deadbeef";
let first = transformer.filter_sensitive_string(input);
let second = transformer.filter_sensitive_string(input);
assert_eq!(first, second);
assert!(!first.contains("secret123"));
assert!(!first.contains("deadbeef"));
}
}