use std::fmt::Display;
use std::time::Duration;
use axum::Json;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use kellnr_common::account_name::AccountNameError;
use kellnr_common::original_name::NameError;
use kellnr_common::version::VersionError;
use kellnr_db::error::DbError;
use kellnr_storage::storage_error::StorageError;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zip::result::ZipError;
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
details: ErrorDetails,
retry_after: Option<Duration>,
}
#[derive(Serialize, Deserialize, Debug, ToSchema)]
pub struct ErrorDetails {
pub errors: Vec<ErrorDetail>,
}
impl From<String> for ErrorDetails {
fn from(e: String) -> Self {
let detail = ErrorDetail { detail: e };
Self {
errors: vec![detail],
}
}
}
#[derive(Serialize, Deserialize, Debug, ToSchema)]
pub struct ErrorDetail {
pub detail: String,
}
impl ApiError {
pub fn new(msg: &str, status: StatusCode) -> Self {
Self::from_str(msg, status)
}
fn from_str(e: &str, status: StatusCode) -> Self {
Self {
status,
details: ErrorDetails::from(format!("ERROR: {e}")),
retry_after: None,
}
}
pub fn bad_request(msg: impl Display) -> Self {
Self::from_str(&msg.to_string(), StatusCode::BAD_REQUEST)
}
pub fn unauthorized(msg: impl Display) -> Self {
Self::from_str(&msg.to_string(), StatusCode::UNAUTHORIZED)
}
pub fn forbidden(msg: impl Display) -> Self {
Self::from_str(&msg.to_string(), StatusCode::FORBIDDEN)
}
pub fn not_found(msg: impl Display) -> Self {
Self::from_str(&msg.to_string(), StatusCode::NOT_FOUND)
}
pub fn conflict(msg: impl Display) -> Self {
Self::from_str(&msg.to_string(), StatusCode::CONFLICT)
}
pub fn service_unavailable(msg: impl Display, retry_after: Duration) -> Self {
Self {
retry_after: Some(retry_after),
..Self::from_str(&msg.to_string(), StatusCode::SERVICE_UNAVAILABLE)
}
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn from_err(e: &dyn std::error::Error, status: StatusCode) -> Self {
let e = format!("ERROR: {e}");
Self {
status,
details: ErrorDetails::from(e),
retry_after: None,
}
}
pub fn internal(e: &dyn std::error::Error) -> Self {
tracing::error!("Internal server error: {e}");
Self::from_str("Internal server error", StatusCode::INTERNAL_SERVER_ERROR)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut response = (self.status, Json(self.details)).into_response();
if let Some(retry_after) = self.retry_after {
response
.headers_mut()
.insert(header::RETRY_AFTER, retry_after.as_secs().into());
}
response
}
}
pub struct PlainTextError {
status: StatusCode,
message: String,
}
impl PlainTextError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct KeepPlainText;
impl IntoResponse for PlainTextError {
fn into_response(self) -> Response {
let mut response = (self.status, self.message).into_response();
response.extensions_mut().insert(KeepPlainText);
response
}
}
impl From<StatusCode> for ApiError {
fn from(status: StatusCode) -> Self {
Self::from_str(status.canonical_reason().unwrap_or("Error"), status)
}
}
impl From<AccountNameError> for ApiError {
fn from(e: AccountNameError) -> Self {
ApiError::from_err(&e, StatusCode::BAD_REQUEST)
}
}
impl From<NameError> for ApiError {
fn from(e: NameError) -> Self {
ApiError::from_err(&e, StatusCode::BAD_REQUEST)
}
}
impl From<VersionError> for ApiError {
fn from(e: VersionError) -> Self {
ApiError::from_err(&e, StatusCode::BAD_REQUEST)
}
}
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
ApiError::internal(&e)
}
}
impl From<ZipError> for ApiError {
fn from(e: ZipError) -> Self {
match e {
ZipError::Io(e) => ApiError::internal(&e),
ZipError::InvalidArchive(s) => ApiError::from_str(&s, StatusCode::BAD_REQUEST),
ZipError::UnsupportedArchive(s) => ApiError::from_str(s, StatusCode::BAD_REQUEST),
ZipError::FileNotFound => {
ApiError::from_str("Zip archive not found", StatusCode::NOT_FOUND)
}
_ => ApiError::from_str("Unknown zip error", StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
impl Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.details.errors[0].detail)
}
}
impl From<DbError> for ApiError {
fn from(e: DbError) -> Self {
match e {
DbError::NotOwner(_) => ApiError::from_err(&e, StatusCode::FORBIDDEN),
DbError::UserNotFound(_)
| DbError::GroupNotFound(_)
| DbError::OwnerNotFound(_)
| DbError::CrateNotFound(_)
| DbError::CrateNotFoundWithId(_)
| DbError::CrateMetaNotFound(_, _)
| DbError::CrateIndexNotFound(_, _)
| DbError::TokenNotFound
| DbError::SessionNotFound
| DbError::WebhookNotFound
| DbError::ToolchainNotFound(_, _) => ApiError::from_err(&e, StatusCode::NOT_FOUND),
DbError::PasswordMismatch
| DbError::CrateNameConflict(_, _)
| DbError::CrateNameMismatch(_, _)
| DbError::InvalidCrateName(_)
| DbError::InvalidVersion(_)
| DbError::InvalidWebhookEvent(_)
| DbError::InvalidId(_) => ApiError::from_err(&e, StatusCode::BAD_REQUEST),
DbError::CrateVersionExists(_, _) | DbError::LastOwner => {
ApiError::from_err(&e, StatusCode::CONFLICT)
}
_ if e.is_unique_violation() => {
tracing::warn!("Conflict with an existing entry: {e}");
ApiError::from_str("The entry already exists", StatusCode::CONFLICT)
}
_ => ApiError::internal(&e),
}
}
}
impl From<StorageError> for ApiError {
fn from(e: StorageError) -> Self {
match e {
StorageError::CrateExists(_, _) | StorageError::ToolchainArchiveExists { .. } => {
ApiError::from_err(&e, StatusCode::CONFLICT)
}
_ => ApiError::internal(&e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_prefixes_the_message() {
let e = ApiError::new("Unauthorized", StatusCode::UNAUTHORIZED);
assert_eq!(StatusCode::UNAUTHORIZED, e.status);
assert_eq!(1, e.details.errors.len());
assert_eq!("ERROR: Unauthorized", e.to_string());
}
#[test]
fn service_unavailable_sends_retry_after() {
let response =
ApiError::service_unavailable("down", Duration::from_secs(30)).into_response();
assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
assert_eq!("30", response.headers()[header::RETRY_AFTER]);
}
#[test]
fn database_errors_are_not_leaked() {
let e = ApiError::from(DbError::PostgresError(sea_orm::DbErr::Custom(
"SELECT secret FROM users".to_string(),
)));
assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, e.status);
assert_eq!("ERROR: Internal server error", e.to_string());
}
#[test]
fn user_facing_database_errors_stay_descriptive() {
let e = ApiError::from(DbError::UserNotFound("bob".to_string()));
assert_eq!("ERROR: User not found: bob", e.to_string());
}
#[test]
fn missing_entries_are_not_found() {
for e in [
DbError::CrateNotFound("foo".to_string()),
DbError::OwnerNotFound("bob".to_string()),
DbError::TokenNotFound,
DbError::ToolchainNotFound("stable".to_string(), "x86_64".to_string()),
] {
assert_eq!(StatusCode::NOT_FOUND, ApiError::from(e).status);
}
}
#[test]
fn invalid_input_is_a_bad_request() {
for e in [
DbError::InvalidCrateName("f o o".to_string()),
DbError::InvalidVersion("x".to_string()),
DbError::InvalidId("x".to_string()),
DbError::PasswordMismatch,
] {
assert_eq!(StatusCode::BAD_REQUEST, ApiError::from(e).status);
}
}
#[test]
fn storage_and_io_errors_are_not_leaked() {
let storage = ApiError::from(StorageError::GenericError("/var/lib/kellnr".to_string()));
let io = ApiError::from(std::io::Error::other("/var/lib/kellnr"));
assert_eq!("ERROR: Internal server error", storage.to_string());
assert_eq!("ERROR: Internal server error", io.to_string());
}
#[test]
fn crate_exists_storage_error_stays_descriptive() {
let e = ApiError::from(StorageError::CrateExists(
"foo".to_string(),
"1.0.0".to_string(),
));
assert_eq!(StatusCode::CONFLICT, e.status);
assert_eq!(
"ERROR: Crate with version already exists: foo-1.0.0",
e.to_string()
);
}
}