use serde::Serialize;
use crate::validate::ValidationError;
pub trait TautError: Serialize + Sized {
fn code(&self) -> &'static str;
fn http_status(&self) -> u16 {
400
}
}
#[derive(Debug, Clone, Serialize, thiserror::Error)]
#[serde(tag = "code", content = "payload", rename_all = "snake_case")]
pub enum StandardError {
#[error("bad request: {message}")]
BadRequest {
message: String,
},
#[error("validation failed")]
#[serde(rename = "validation_error")]
ValidationFailed {
errors: Vec<ValidationError>,
},
#[error("unauthenticated")]
Unauthenticated,
#[error("forbidden: {reason}")]
Forbidden {
reason: String,
},
#[error("not found")]
NotFound,
#[error("conflict: {message}")]
Conflict {
message: String,
},
#[error("unprocessable entity: {message}")]
UnprocessableEntity {
message: String,
},
#[error("rate limited (retry after {retry_after_seconds}s)")]
RateLimited {
retry_after_seconds: u32,
},
#[error("internal error")]
Internal,
#[error("service unavailable (retry after {retry_after_seconds}s)")]
ServiceUnavailable {
retry_after_seconds: u32,
},
#[error("timeout")]
Timeout,
}
impl TautError for StandardError {
fn code(&self) -> &'static str {
match self {
Self::BadRequest { .. } => "bad_request",
Self::ValidationFailed { .. } => "validation_error",
Self::Unauthenticated => "unauthenticated",
Self::Forbidden { .. } => "forbidden",
Self::NotFound => "not_found",
Self::Conflict { .. } => "conflict",
Self::UnprocessableEntity { .. } => "unprocessable_entity",
Self::RateLimited { .. } => "rate_limited",
Self::Internal => "internal",
Self::ServiceUnavailable { .. } => "service_unavailable",
Self::Timeout => "timeout",
}
}
#[allow(clippy::match_same_arms)] fn http_status(&self) -> u16 {
match self {
Self::BadRequest { .. } => 400,
Self::ValidationFailed { .. } => 400,
Self::Unauthenticated => 401,
Self::Forbidden { .. } => 403,
Self::NotFound => 404,
Self::Conflict { .. } => 409,
Self::UnprocessableEntity { .. } => 422,
Self::RateLimited { .. } => 429,
Self::Internal => 500,
Self::ServiceUnavailable { .. } => 503,
Self::Timeout => 504,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_unauthenticated() {
assert_eq!(StandardError::Unauthenticated.code(), "unauthenticated");
}
#[test]
fn code_forbidden() {
assert_eq!(
StandardError::Forbidden { reason: "x".into() }.code(),
"forbidden"
);
}
#[test]
fn code_not_found() {
assert_eq!(StandardError::NotFound.code(), "not_found");
}
#[test]
fn code_rate_limited() {
assert_eq!(
StandardError::RateLimited {
retry_after_seconds: 5
}
.code(),
"rate_limited"
);
}
#[test]
fn code_internal() {
assert_eq!(StandardError::Internal.code(), "internal");
}
#[test]
fn http_status_unauthenticated() {
assert_eq!(StandardError::Unauthenticated.http_status(), 401);
}
#[test]
fn http_status_forbidden() {
assert_eq!(
StandardError::Forbidden { reason: "x".into() }.http_status(),
403
);
}
#[test]
fn http_status_not_found() {
assert_eq!(StandardError::NotFound.http_status(), 404);
}
#[test]
fn http_status_rate_limited() {
assert_eq!(
StandardError::RateLimited {
retry_after_seconds: 5
}
.http_status(),
429
);
}
#[test]
fn http_status_internal() {
assert_eq!(StandardError::Internal.http_status(), 500);
}
#[test]
fn serialize_forbidden_contains_code_and_payload() {
let err = StandardError::Forbidden {
reason: "test".into(),
};
let json = serde_json::to_string(&err).expect("serialize StandardError");
assert!(
json.contains("\"code\":\"forbidden\""),
"expected code field in {json}"
);
assert!(
json.contains("\"reason\":\"test\""),
"expected payload reason in {json}"
);
}
#[test]
fn code_bad_request() {
assert_eq!(
StandardError::BadRequest {
message: "x".into()
}
.code(),
"bad_request"
);
}
#[test]
fn code_conflict() {
assert_eq!(
StandardError::Conflict {
message: "x".into()
}
.code(),
"conflict"
);
}
#[test]
fn code_unprocessable_entity() {
assert_eq!(
StandardError::UnprocessableEntity {
message: "x".into()
}
.code(),
"unprocessable_entity"
);
}
#[test]
fn code_service_unavailable() {
assert_eq!(
StandardError::ServiceUnavailable {
retry_after_seconds: 5
}
.code(),
"service_unavailable"
);
}
#[test]
fn code_timeout() {
assert_eq!(StandardError::Timeout.code(), "timeout");
}
#[test]
fn http_status_bad_request() {
assert_eq!(
StandardError::BadRequest {
message: "x".into()
}
.http_status(),
400
);
}
#[test]
fn http_status_conflict() {
assert_eq!(
StandardError::Conflict {
message: "x".into()
}
.http_status(),
409
);
}
#[test]
fn http_status_unprocessable_entity() {
assert_eq!(
StandardError::UnprocessableEntity {
message: "x".into()
}
.http_status(),
422
);
}
#[test]
fn http_status_service_unavailable() {
assert_eq!(
StandardError::ServiceUnavailable {
retry_after_seconds: 5
}
.http_status(),
503
);
}
#[test]
fn http_status_timeout() {
assert_eq!(StandardError::Timeout.http_status(), 504);
}
#[test]
fn serialize_bad_request_contains_code_and_message() {
let err = StandardError::BadRequest {
message: "x".into(),
};
let json = serde_json::to_string(&err).expect("serialize StandardError");
assert!(
json.contains("\"code\":\"bad_request\""),
"expected code field in {json}"
);
assert!(
json.contains("\"message\":\"x\""),
"expected payload message in {json}"
);
}
#[test]
fn code_validation_failed() {
assert_eq!(
StandardError::ValidationFailed { errors: vec![] }.code(),
"validation_error"
);
}
#[test]
fn http_status_validation_failed() {
assert_eq!(
StandardError::ValidationFailed { errors: vec![] }.http_status(),
400
);
}
#[test]
fn serialize_validation_failed_with_errors() {
let err = StandardError::ValidationFailed {
errors: vec![ValidationError {
path: "name".into(),
constraint: "length".into(),
message: "too short".into(),
}],
};
let json = serde_json::to_string(&err).expect("serialize StandardError");
assert!(
json.contains("\"code\":\"validation_error\""),
"expected code field in {json}"
);
assert!(
json.contains("\"errors\":[{"),
"expected errors array in {json}"
);
assert!(
json.contains("\"path\":\"name\""),
"expected path in {json}"
);
assert!(
json.contains("\"constraint\":\"length\""),
"expected constraint in {json}"
);
assert!(
json.contains("\"message\":\"too short\""),
"expected message in {json}"
);
}
}