summaryrefslogtreecommitdiff
path: root/server/src/error.rs
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/error.rs')
-rw-r--r--server/src/error.rs145
1 files changed, 145 insertions, 0 deletions
diff --git a/server/src/error.rs b/server/src/error.rs
new file mode 100644
index 0000000..0342afd
--- /dev/null
+++ b/server/src/error.rs
@@ -0,0 +1,145 @@
+use axum::{
+ http::StatusCode,
+ response::{IntoResponse, Response},
+ Json,
+};
+use serde_json::{json, Value};
+
+/// Coherent error type for all handlers and helpers. Exactly one set of
+/// constructors, one IntoResponse, and the three owned From conversions
+/// (rusqlite, std::io, serde_json) live here and nowhere else.
+#[derive(Debug)]
+pub struct AppError {
+ pub status: StatusCode,
+ pub code: &'static str,
+ pub message: String,
+ pub details: Option<Value>,
+}
+
+impl std::fmt::Display for AppError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}: {}", self.code, self.message)
+ }
+}
+
+impl std::error::Error for AppError {}
+
+impl AppError {
+ pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
+ AppError { status, code, message: message.into(), details: None }
+ }
+ pub fn bad(code: &'static str, msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::BAD_REQUEST, code, msg)
+ }
+ pub fn unauthorized(msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::UNAUTHORIZED, "unauthorized", msg)
+ }
+ pub fn forbidden(msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::FORBIDDEN, "forbidden", msg)
+ }
+ pub fn not_found(msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::NOT_FOUND, "not_found", msg)
+ }
+ pub fn conflict(code: &'static str, msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::CONFLICT, code, msg)
+ }
+ pub fn internal(msg: impl Into<String>) -> Self {
+ Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal", msg)
+ }
+ pub fn with_details(mut self, d: Value) -> Self {
+ self.details = Some(d);
+ self
+ }
+ pub fn with_code(mut self, code: &'static str) -> Self {
+ self.code = code;
+ self
+ }
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> Response {
+ let mut e = json!({"code": self.code, "message": self.message});
+ if let Some(details) = self.details {
+ e["details"] = details;
+ }
+ (self.status, Json(json!({"error": e}))).into_response()
+ }
+}
+
+impl From<rusqlite::Error> for AppError {
+ fn from(e: rusqlite::Error) -> Self {
+ match &e {
+ rusqlite::Error::QueryReturnedNoRows => AppError::not_found("resource not found"),
+ _ => {
+ tracing::error!("db error: {e}");
+ AppError::internal("internal storage error")
+ }
+ }
+ }
+}
+
+impl From<std::io::Error> for AppError {
+ fn from(e: std::io::Error) -> Self {
+ tracing::error!("io error: {e}");
+ AppError::internal("internal filesystem error")
+ }
+}
+
+impl From<serde_json::Error> for AppError {
+ fn from(e: serde_json::Error) -> Self {
+ tracing::error!("json error: {e}");
+ AppError::internal("internal json error")
+ }
+}
+
+pub type AppResult<T> = Result<T, AppError>;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn constructors_map_to_expected_status_and_code() {
+ let e = AppError::bad("validation", "x");
+ assert_eq!(e.status, StatusCode::BAD_REQUEST);
+ assert_eq!(e.code, "validation");
+ assert_eq!(AppError::unauthorized("y").status, StatusCode::UNAUTHORIZED);
+ assert_eq!(AppError::forbidden("y").status, StatusCode::FORBIDDEN);
+ assert_eq!(AppError::not_found("y").status, StatusCode::NOT_FOUND);
+ assert_eq!(AppError::conflict("stale", "y").status, StatusCode::CONFLICT);
+ assert_eq!(AppError::internal("y").status, StatusCode::INTERNAL_SERVER_ERROR);
+ let v = AppError::conflict("stale", "y")
+ .with_code("dataset_not_ready")
+ .with_details(json!({"warnings": [1]}));
+ assert_eq!(v.code, "dataset_not_ready");
+ assert_eq!(v.details.expect("details")["warnings"], json!([1]));
+ }
+
+ #[tokio::test]
+ async fn body_shape_is_error_code_message_details() {
+ let mut resp = AppError::conflict("stale", "expected_generation out of date")
+ .with_details(json!({"expected": 3}))
+ .into_response();
+ let bytes = axum::body::to_bytes(std::mem::take(resp.body_mut()), 64 * 1024).await.unwrap();
+ let parsed: Value = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(parsed["error"]["code"], "stale");
+ assert_eq!(parsed["error"]["message"], "expected_generation out of date");
+ assert_eq!(parsed["error"]["details"]["expected"], 3);
+
+ let mut plain = AppError::internal("boom").into_response();
+ let plain: Value = serde_json::from_slice(
+ &axum::body::to_bytes(std::mem::take(plain.body_mut()), 64 * 1024).await.unwrap(),
+ ).unwrap();
+ assert!(plain["error"].get("details").is_none());
+ }
+
+ #[test]
+ fn from_impls_convert_without_duplicates() {
+ let e: AppError = rusqlite::Error::QueryReturnedNoRows.into();
+ assert_eq!(e.status, StatusCode::NOT_FOUND);
+ let e: AppError = std::io::Error::new(std::io::ErrorKind::NotFound, "nope").into();
+ assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR);
+ let e: AppError = serde_json::from_str::<Value>("{").unwrap_err().into();
+ assert_eq!(e.status, StatusCode::INTERNAL_SERVER_ERROR);
+ }
+}