use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("version mismatch: current={current} sent={sent:?}")]
VersionMismatch {
current: u64,
sent: String,
},
#[error("db error: {0}")]
Db(#[from] rusqlite::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("internal error: {0}")]
Internal(String),
}
impl ApiError {
#[must_use]
pub fn status(&self) -> StatusCode {
match self {
Self::NotFound => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::VersionMismatch { .. } => StatusCode::CONFLICT,
Self::Db(_) | Self::Json(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::NotFound => "not_found",
Self::BadRequest(_) => "bad_request",
Self::VersionMismatch { .. } => "version_mismatch",
Self::Db(_) | Self::Json(_) | Self::Internal(_) => "internal",
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
let kind = self.kind();
let body: Value = match &self {
Self::NotFound => json!({"error": kind, "message": "not found"}),
Self::BadRequest(msg) => json!({"error": kind, "message": msg}),
Self::VersionMismatch { current, sent } => {
json!({
"error": kind,
"current": current,
"sent": sent,
})
}
Self::Db(e) => {
tracing::error!(error = %e, "db error");
json!({"error": kind, "message": "internal error"})
}
Self::Json(e) => {
tracing::error!(error = %e, "json error");
json!({"error": kind, "message": "internal error"})
}
Self::Internal(msg) => {
tracing::error!(error = %msg, "internal error");
json!({"error": kind, "message": "internal error"})
}
};
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::ApiError;
use axum::response::IntoResponse;
#[test]
fn version_mismatch_is_409() {
let resp = ApiError::VersionMismatch {
current: 5,
sent: "bogus".into(),
}
.into_response();
assert_eq!(resp.status().as_u16(), 409);
}
#[test]
fn not_found_is_404() {
let resp = ApiError::NotFound.into_response();
assert_eq!(resp.status().as_u16(), 404);
}
#[test]
fn bad_request_is_400() {
let resp = ApiError::BadRequest("nope".into()).into_response();
assert_eq!(resp.status().as_u16(), 400);
}
#[test]
fn db_error_is_500() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
let err = conn.prepare("SELECT * FROM does_not_exist").unwrap_err();
let resp = ApiError::Db(err).into_response();
assert_eq!(resp.status().as_u16(), 500);
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SimError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("bind {addr}: {source}")]
Bind {
addr: String,
#[source]
source: std::io::Error,
},
#[error("api: {0}")]
Api(#[from] ApiError),
}
pub type Result<T> = std::result::Result<T, SimError>;
#[cfg(test)]
mod sim_error_tests {
use super::{ApiError, SimError};
#[test]
fn from_io_error_preserves_kind() {
let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "nope");
let sim: SimError = io.into();
assert!(
matches!(sim, SimError::Io(ref e) if e.kind() == std::io::ErrorKind::PermissionDenied)
);
}
#[test]
fn from_api_error_routes_to_api_variant() {
let sim: SimError = ApiError::NotFound.into();
assert!(matches!(sim, SimError::Api(ApiError::NotFound)));
}
#[test]
fn bind_variant_renders_address() {
let sim = SimError::Bind {
addr: "127.0.0.1:7878".into(),
source: std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use"),
};
let rendered = sim.to_string();
assert!(rendered.contains("127.0.0.1:7878"), "got: {rendered}");
}
#[test]
fn anyhow_can_absorb_sim_error_via_std_error() {
fn returns_sim_err() -> Result<(), SimError> {
Err(SimError::Io(std::io::Error::other("boom")))
}
fn returns_anyhow() -> anyhow::Result<()> {
returns_sim_err()?;
Ok(())
}
assert!(returns_anyhow().is_err());
}
}