use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use logdive_core::{LogdiveError, QueryParseError};
#[derive(Debug)]
pub enum AppError {
BadRequest(String),
#[allow(dead_code)]
NotFound(String),
Internal(LogdiveError),
}
impl AppError {
pub fn bad_request<M: std::fmt::Display>(msg: M) -> Self {
Self::BadRequest(msg.to_string())
}
}
impl From<LogdiveError> for AppError {
fn from(err: LogdiveError) -> Self {
match &err {
LogdiveError::QueryParse(_)
| LogdiveError::InvalidDatetime { .. }
| LogdiveError::UnsafeFieldName(_) => AppError::BadRequest(err.to_string()),
_ => AppError::Internal(err),
}
}
}
impl From<QueryParseError> for AppError {
fn from(err: QueryParseError) -> Self {
AppError::from(LogdiveError::from(err))
}
}
#[derive(Debug, Serialize)]
struct ErrorBody<'a> {
error: &'a str,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::BadRequest(msg) => {
tracing::debug!(%msg, "400 bad request");
(StatusCode::BAD_REQUEST, msg)
}
AppError::NotFound(msg) => {
tracing::debug!(%msg, "404 not found");
(StatusCode::NOT_FOUND, msg)
}
AppError::Internal(err) => {
tracing::warn!(error = %err, "500 internal server error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
};
(status, Json(ErrorBody { error: &message })).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
use logdive_core::parse_query;
use serde_json::Value;
async fn read_body(resp: Response) -> (StatusCode, String) {
let status = resp.status();
let body = resp
.into_body()
.collect()
.await
.expect("collect body")
.to_bytes();
let text = String::from_utf8(body.to_vec()).expect("utf-8 body");
(status, text)
}
fn parse_error_body(text: &str) -> String {
let v: Value = serde_json::from_str(text).expect("response body is JSON");
v.get("error")
.and_then(|e| e.as_str())
.expect("body has `error` string field")
.to_string()
}
#[tokio::test]
async fn bad_request_renders_400_with_user_message() {
let err = AppError::BadRequest("missing `q` parameter".to_string());
let (status, text) = read_body(err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(parse_error_body(&text), "missing `q` parameter");
}
#[tokio::test]
async fn not_found_renders_404_with_user_message() {
let err = AppError::NotFound("no such entry".to_string());
let (status, text) = read_body(err.into_response()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(parse_error_body(&text), "no such entry");
}
#[tokio::test]
async fn internal_renders_500_with_generic_message() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing.db");
let inner =
logdive_core::Indexer::open_read_only(&missing).expect_err("should fail on missing db");
let err = AppError::Internal(inner);
let (status, text) = read_body(err.into_response()).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(parse_error_body(&text), "internal server error");
}
#[tokio::test]
async fn from_logdive_error_maps_query_parse_to_bad_request() {
let query_err = parse_query("level =").expect_err("should not parse");
let app_err: AppError = LogdiveError::from(query_err).into();
let (status, text) = read_body(app_err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_ne!(parse_error_body(&text), "internal server error");
assert!(!parse_error_body(&text).is_empty());
}
#[tokio::test]
async fn from_query_parse_error_directly_maps_to_bad_request() {
let query_err = parse_query("level =").expect_err("should not parse");
let app_err: AppError = query_err.into();
let (status, text) = read_body(app_err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_ne!(parse_error_body(&text), "internal server error");
}
#[tokio::test]
async fn from_logdive_error_maps_invalid_datetime_to_bad_request() {
let err = LogdiveError::InvalidDatetime {
input: "not-a-date".to_string(),
reason: "bad format".to_string(),
};
let app_err: AppError = err.into();
let (status, text) = read_body(app_err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
parse_error_body(&text)
.to_lowercase()
.contains("not-a-date")
);
}
#[tokio::test]
async fn from_logdive_error_maps_unsafe_field_name_to_bad_request() {
let err = LogdiveError::UnsafeFieldName("service; DROP TABLE--".to_string());
let app_err: AppError = err.into();
let (status, _) = read_body(app_err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn from_logdive_error_maps_other_to_internal() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("also-missing.db");
let inner =
logdive_core::Indexer::open_read_only(&missing).expect_err("should fail on missing db");
let app_err: AppError = inner.into();
let (status, text) = read_body(app_err.into_response()).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(parse_error_body(&text), "internal server error");
}
#[test]
fn bad_request_constructor_accepts_anything_displayable() {
let _a: AppError = AppError::bad_request("literal");
let _b: AppError = AppError::bad_request(String::from("owned"));
let _c: AppError = AppError::bad_request(format_args!("formatted {}", 1));
}
}