use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("unauthenticated")]
Unauthorized,
#[error("{message}")]
NotFound {
code: &'static str,
message: String,
},
#[error("{message}")]
BadRequest {
code: &'static str,
message: String,
},
#[error("{0}")]
Internal(String),
}
impl ApiError {
pub fn unknown_index(id: &str) -> Self {
ApiError::NotFound {
code: "unknown_index",
message: format!("unknown index id '{id}'"),
}
}
pub fn unknown_node(id: &str) -> Self {
ApiError::NotFound {
code: "unknown_node",
message: format!("unknown node '{id}'"),
}
}
pub fn no_path(from: &str, to: &str) -> Self {
ApiError::NotFound {
code: "no_path",
message: format!("no typed path between '{from}' and '{to}'"),
}
}
pub fn missing_param(name: &str) -> Self {
ApiError::BadRequest {
code: "missing_param",
message: format!("missing required query parameter '{name}'"),
}
}
pub fn invalid_param(name: &str, reason: &str) -> Self {
ApiError::BadRequest {
code: "invalid_param",
message: format!("invalid query parameter '{name}': {reason}"),
}
}
pub fn invalid_body(reason: &str) -> Self {
ApiError::BadRequest {
code: "invalid_body",
message: format!("invalid request body: {reason}"),
}
}
pub fn invalid_repo_path(reason: &str) -> Self {
ApiError::BadRequest {
code: "invalid_repo_path",
message: format!("invalid repo_path: {reason}"),
}
}
pub fn internal(reason: impl Into<String>) -> Self {
ApiError::Internal(reason.into())
}
fn status(&self) -> StatusCode {
match self {
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::NotFound { .. } => StatusCode::NOT_FOUND,
ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn code(&self) -> &'static str {
match self {
ApiError::Unauthorized => "unauthenticated",
ApiError::NotFound { code, .. } | ApiError::BadRequest { code, .. } => code,
ApiError::Internal(_) => "internal",
}
}
pub fn body(&self) -> serde_json::Value {
json!({ "error": self.code(), "message": self.to_string() })
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status(), Json(self.body())).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_matches_the_kind_of_failure() {
assert_eq!(ApiError::Unauthorized.status(), StatusCode::UNAUTHORIZED);
assert_eq!(ApiError::unknown_index("x").status(), StatusCode::NOT_FOUND);
assert_eq!(ApiError::unknown_node("x").status(), StatusCode::NOT_FOUND);
assert_eq!(ApiError::no_path("a", "b").status(), StatusCode::NOT_FOUND);
assert_eq!(
ApiError::missing_param("node").status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
ApiError::invalid_param("limit", "not an integer").status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
ApiError::invalid_body("bad json").status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
ApiError::invalid_repo_path("missing").status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
ApiError::internal("boom").status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn design_codes_are_all_reachable() {
for code in [
"unauthenticated",
"unknown_index",
"unknown_node",
"missing_param",
"invalid_param",
"invalid_body",
"invalid_repo_path",
"internal",
] {
let err = match code {
"unauthenticated" => ApiError::Unauthorized,
"unknown_index" => ApiError::unknown_index("id"),
"unknown_node" => ApiError::unknown_node("id"),
"missing_param" => ApiError::missing_param("node"),
"invalid_param" => ApiError::invalid_param("limit", "out of range"),
"invalid_body" => ApiError::invalid_body("not json"),
"invalid_repo_path" => ApiError::invalid_repo_path("not a directory"),
_ => ApiError::internal("boom"),
};
assert_eq!(err.code(), code);
}
}
#[test]
fn body_is_flat_with_code_and_message() {
let body = ApiError::unknown_index("abc").body();
let obj = body.as_object().expect("body is an object");
assert_eq!(obj.len(), 2, "flat body has exactly error+message: {body}");
assert_eq!(obj["error"], "unknown_index");
assert!(obj["message"].as_str().unwrap().contains("abc"));
assert!(body.get("status").is_none());
}
#[test]
fn nonexistent_is_404_and_malformed_is_400() {
assert_eq!(ApiError::unknown_index("i").code(), "unknown_index");
assert_eq!(ApiError::unknown_node("n").code(), "unknown_node");
assert_eq!(ApiError::unknown_index("i").status(), StatusCode::NOT_FOUND);
assert_eq!(ApiError::missing_param("node").code(), "missing_param");
assert_eq!(
ApiError::missing_param("node").status(),
StatusCode::BAD_REQUEST
);
}
#[test]
fn message_carries_detail_not_internals() {
assert!(ApiError::missing_param("node").to_string().contains("node"));
assert!(ApiError::invalid_param("limit", "must be 1..=1000")
.to_string()
.contains("1..=1000"));
assert_eq!(ApiError::Unauthorized.to_string(), "unauthenticated");
}
}