//! Errors shared by the application layers. use std::fmt::Display; use thiserror::Error; pub type Result = std::result::Result; /// Classify a boundary failure while retaining its original diagnostic. pub(crate) trait ErrorContext { fn context(self, kind: impl FnOnce(String) -> Error, message: impl Display) -> Result; } impl ErrorContext for Result { fn context(self, kind: impl FnOnce(String) -> Error, message: impl Display) -> Result { self.map_err(|error| kind(format!("{message}: {error}"))) } } impl From for Error { fn from(error: cpds_protocol::Error) -> Self { let cpds_protocol::Error::InvalidRecord(message) = error; Self::InvalidRequest(message) } } #[derive(Debug, Error)] pub enum Error { #[error("authentication is required")] AuthenticationRequired, #[error("authentication failed: {message}")] Authentication { name: &'static str, message: String }, #[error("authorization failed: {0}")] Authorization(String), #[error("the requested object does not exist")] NotFound, #[error("the request conflicts with existing state: {0}")] Conflict(String), #[error("the request is invalid: {0}")] InvalidRequest(String), #[error("authenticated principal request rate exceeded")] RateLimited { retry_after: u64 }, #[error("{message}")] Xrpc { name: &'static str, message: String }, #[error("configuration error: {0}")] Configuration(String), #[error("database error: {0}")] Database(#[from] rusqlite::Error), #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("URL error: {0}")] Url(#[from] url::ParseError), #[error("upstream service error: {0}")] Upstream(String), #[error("internal error: {0}")] Internal(String), } impl Error { pub(crate) fn xrpc(name: &'static str, message: impl Into) -> Self { Self::Xrpc { name, message: message.into(), } } } #[cfg(test)] mod tests { use axum::{body::to_bytes, http::StatusCode, response::IntoResponse as _}; use super::Error; #[tokio::test] async fn xrpc_constructor_preserves_borrowed_and_owned_message_responses() { let message = "details with \"quotes\", a newline\n and café"; for (name, status) in [ ("SpaceNotFound", StatusCode::BAD_REQUEST), ("AccountSuspended", StatusCode::FORBIDDEN), ] { let original = Error::Xrpc { name, message: message.to_owned(), } .into_response(); assert_eq!(original.status(), status); let headers = original.headers().clone(); let body = to_bytes(original.into_body(), usize::MAX).await.unwrap(); for error in [ Error::xrpc(name, message), Error::xrpc(name, message.to_owned()), ] { assert!( matches!(&error, Error::Xrpc { name: actual_name, message: actual_message } if *actual_name == name && actual_message == message) ); assert_eq!(error.to_string(), message); let response = error.into_response(); assert_eq!(response.status(), status); assert_eq!(response.headers(), &headers); assert_eq!( to_bytes(response.into_body(), usize::MAX).await.unwrap(), body ); } } } }