Skip to main content

kellnr_http/
error.rs

1use std::fmt::Display;
2use std::time::Duration;
3
4use axum::Json;
5use axum::http::{StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use kellnr_common::account_name::AccountNameError;
8use kellnr_common::original_name::NameError;
9use kellnr_common::version::VersionError;
10use kellnr_db::error::DbError;
11use kellnr_storage::storage_error::StorageError;
12use serde::{Deserialize, Serialize};
13use utoipa::ToSchema;
14use zip::result::ZipError;
15
16pub type ApiResult<T> = Result<T, ApiError>;
17
18/// An error response of every Kellnr endpoint, in the format of the cargo
19/// registry API: `{"errors":[{"detail":"..."}]}`.
20#[derive(Debug)]
21pub struct ApiError {
22    status: StatusCode,
23    details: ErrorDetails,
24    /// Sent as `Retry-After` header.
25    retry_after: Option<Duration>,
26}
27
28#[derive(Serialize, Deserialize, Debug, ToSchema)]
29pub struct ErrorDetails {
30    pub errors: Vec<ErrorDetail>,
31}
32
33impl From<String> for ErrorDetails {
34    fn from(e: String) -> Self {
35        let detail = ErrorDetail { detail: e };
36        Self {
37            errors: vec![detail],
38        }
39    }
40}
41
42#[derive(Serialize, Deserialize, Debug, ToSchema)]
43pub struct ErrorDetail {
44    pub detail: String,
45}
46
47impl ApiError {
48    /// An error with `msg` as its detail.
49    pub fn new(msg: &str, status: StatusCode) -> Self {
50        Self::from_str(msg, status)
51    }
52
53    fn from_str(e: &str, status: StatusCode) -> Self {
54        Self {
55            status,
56            details: ErrorDetails::from(format!("ERROR: {e}")),
57            retry_after: None,
58        }
59    }
60
61    pub fn bad_request(msg: impl Display) -> Self {
62        Self::from_str(&msg.to_string(), StatusCode::BAD_REQUEST)
63    }
64
65    pub fn unauthorized(msg: impl Display) -> Self {
66        Self::from_str(&msg.to_string(), StatusCode::UNAUTHORIZED)
67    }
68
69    pub fn forbidden(msg: impl Display) -> Self {
70        Self::from_str(&msg.to_string(), StatusCode::FORBIDDEN)
71    }
72
73    pub fn not_found(msg: impl Display) -> Self {
74        Self::from_str(&msg.to_string(), StatusCode::NOT_FOUND)
75    }
76
77    pub fn conflict(msg: impl Display) -> Self {
78        Self::from_str(&msg.to_string(), StatusCode::CONFLICT)
79    }
80
81    /// A 503 error that asks the client to retry after `retry_after`.
82    pub fn service_unavailable(msg: impl Display, retry_after: Duration) -> Self {
83        Self {
84            retry_after: Some(retry_after),
85            ..Self::from_str(&msg.to_string(), StatusCode::SERVICE_UNAVAILABLE)
86        }
87    }
88
89    pub fn status(&self) -> StatusCode {
90        self.status
91    }
92
93    pub fn from_err(e: &dyn std::error::Error, status: StatusCode) -> Self {
94        let e = format!("ERROR: {e}");
95        Self {
96            status,
97            details: ErrorDetails::from(e),
98            retry_after: None,
99        }
100    }
101
102    /// Log `e` server-side and return a generic 500 error that does not leak
103    /// internal details like SQL statements or storage paths to the client.
104    pub fn internal(e: &dyn std::error::Error) -> Self {
105        tracing::error!("Internal server error: {e}");
106        Self::from_str("Internal server error", StatusCode::INTERNAL_SERVER_ERROR)
107    }
108}
109
110impl IntoResponse for ApiError {
111    fn into_response(self) -> Response {
112        let mut response = (self.status, Json(self.details)).into_response();
113        if let Some(retry_after) = self.retry_after {
114            response
115                .headers_mut()
116                .insert(header::RETRY_AFTER, retry_after.as_secs().into());
117        }
118        response
119    }
120}
121
122/// An error with a plain text body, for responses whose body cargo shows as
123/// it is, like a denied crate download. It stays plain text, see
124/// [`errors_as_json`](crate::middleware::errors_as_json).
125pub struct PlainTextError {
126    status: StatusCode,
127    message: String,
128}
129
130impl PlainTextError {
131    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
132        Self {
133            status,
134            message: message.into(),
135        }
136    }
137}
138
139/// Marks a response of [`PlainTextError`].
140#[derive(Clone, Copy)]
141pub(crate) struct KeepPlainText;
142
143impl IntoResponse for PlainTextError {
144    fn into_response(self) -> Response {
145        let mut response = (self.status, self.message).into_response();
146        response.extensions_mut().insert(KeepPlainText);
147        response
148    }
149}
150
151/// An error without further details than its status code.
152impl From<StatusCode> for ApiError {
153    fn from(status: StatusCode) -> Self {
154        Self::from_str(status.canonical_reason().unwrap_or("Error"), status)
155    }
156}
157
158impl From<AccountNameError> for ApiError {
159    fn from(e: AccountNameError) -> Self {
160        ApiError::from_err(&e, StatusCode::BAD_REQUEST)
161    }
162}
163
164impl From<NameError> for ApiError {
165    fn from(e: NameError) -> Self {
166        ApiError::from_err(&e, StatusCode::BAD_REQUEST)
167    }
168}
169
170impl From<VersionError> for ApiError {
171    fn from(e: VersionError) -> Self {
172        ApiError::from_err(&e, StatusCode::BAD_REQUEST)
173    }
174}
175
176impl From<std::io::Error> for ApiError {
177    fn from(e: std::io::Error) -> Self {
178        ApiError::internal(&e)
179    }
180}
181
182impl From<ZipError> for ApiError {
183    fn from(e: ZipError) -> Self {
184        match e {
185            ZipError::Io(e) => ApiError::internal(&e),
186            ZipError::InvalidArchive(s) => ApiError::from_str(&s, StatusCode::BAD_REQUEST),
187            ZipError::UnsupportedArchive(s) => ApiError::from_str(s, StatusCode::BAD_REQUEST),
188            ZipError::FileNotFound => {
189                ApiError::from_str("Zip archive not found", StatusCode::NOT_FOUND)
190            }
191            _ => ApiError::from_str("Unknown zip error", StatusCode::INTERNAL_SERVER_ERROR),
192        }
193    }
194}
195
196impl Display for ApiError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        write!(f, "{}", self.details.errors[0].detail)
199    }
200}
201
202impl From<DbError> for ApiError {
203    fn from(e: DbError) -> Self {
204        // These only carry user supplied names and are useful to the client.
205        match e {
206            DbError::NotOwner(_) => ApiError::from_err(&e, StatusCode::FORBIDDEN),
207            DbError::UserNotFound(_)
208            | DbError::GroupNotFound(_)
209            | DbError::OwnerNotFound(_)
210            | DbError::CrateNotFound(_)
211            | DbError::CrateNotFoundWithId(_)
212            | DbError::CrateMetaNotFound(_, _)
213            | DbError::CrateIndexNotFound(_, _)
214            | DbError::TokenNotFound
215            | DbError::SessionNotFound
216            | DbError::WebhookNotFound
217            | DbError::ToolchainNotFound(_, _) => ApiError::from_err(&e, StatusCode::NOT_FOUND),
218            DbError::PasswordMismatch
219            | DbError::CrateNameConflict(_, _)
220            | DbError::CrateNameMismatch(_, _)
221            | DbError::InvalidCrateName(_)
222            | DbError::InvalidVersion(_)
223            | DbError::InvalidWebhookEvent(_)
224            | DbError::InvalidId(_) => ApiError::from_err(&e, StatusCode::BAD_REQUEST),
225            DbError::CrateVersionExists(_, _) | DbError::LastOwner => {
226                ApiError::from_err(&e, StatusCode::CONFLICT)
227            }
228            _ if e.is_unique_violation() => {
229                tracing::warn!("Conflict with an existing entry: {e}");
230                ApiError::from_str("The entry already exists", StatusCode::CONFLICT)
231            }
232            _ => ApiError::internal(&e),
233        }
234    }
235}
236
237impl From<StorageError> for ApiError {
238    fn from(e: StorageError) -> Self {
239        match e {
240            StorageError::CrateExists(_, _) | StorageError::ToolchainArchiveExists { .. } => {
241                ApiError::from_err(&e, StatusCode::CONFLICT)
242            }
243            _ => ApiError::internal(&e),
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn new_prefixes_the_message() {
254        let e = ApiError::new("Unauthorized", StatusCode::UNAUTHORIZED);
255
256        assert_eq!(StatusCode::UNAUTHORIZED, e.status);
257        assert_eq!(1, e.details.errors.len());
258        assert_eq!("ERROR: Unauthorized", e.to_string());
259    }
260
261    #[test]
262    fn service_unavailable_sends_retry_after() {
263        let response =
264            ApiError::service_unavailable("down", Duration::from_secs(30)).into_response();
265
266        assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
267        assert_eq!("30", response.headers()[header::RETRY_AFTER]);
268    }
269
270    #[test]
271    fn database_errors_are_not_leaked() {
272        let e = ApiError::from(DbError::PostgresError(sea_orm::DbErr::Custom(
273            "SELECT secret FROM users".to_string(),
274        )));
275
276        assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, e.status);
277        assert_eq!("ERROR: Internal server error", e.to_string());
278    }
279
280    #[test]
281    fn user_facing_database_errors_stay_descriptive() {
282        let e = ApiError::from(DbError::UserNotFound("bob".to_string()));
283
284        assert_eq!("ERROR: User not found: bob", e.to_string());
285    }
286
287    #[test]
288    fn missing_entries_are_not_found() {
289        for e in [
290            DbError::CrateNotFound("foo".to_string()),
291            DbError::OwnerNotFound("bob".to_string()),
292            DbError::TokenNotFound,
293            DbError::ToolchainNotFound("stable".to_string(), "x86_64".to_string()),
294        ] {
295            assert_eq!(StatusCode::NOT_FOUND, ApiError::from(e).status);
296        }
297    }
298
299    #[test]
300    fn invalid_input_is_a_bad_request() {
301        for e in [
302            DbError::InvalidCrateName("f o o".to_string()),
303            DbError::InvalidVersion("x".to_string()),
304            DbError::InvalidId("x".to_string()),
305            DbError::PasswordMismatch,
306        ] {
307            assert_eq!(StatusCode::BAD_REQUEST, ApiError::from(e).status);
308        }
309    }
310
311    #[test]
312    fn storage_and_io_errors_are_not_leaked() {
313        let storage = ApiError::from(StorageError::GenericError("/var/lib/kellnr".to_string()));
314        let io = ApiError::from(std::io::Error::other("/var/lib/kellnr"));
315
316        assert_eq!("ERROR: Internal server error", storage.to_string());
317        assert_eq!("ERROR: Internal server error", io.to_string());
318    }
319
320    #[test]
321    fn crate_exists_storage_error_stays_descriptive() {
322        let e = ApiError::from(StorageError::CrateExists(
323            "foo".to_string(),
324            "1.0.0".to_string(),
325        ));
326
327        assert_eq!(StatusCode::CONFLICT, e.status);
328        assert_eq!(
329            "ERROR: Crate with version already exists: foo-1.0.0",
330            e.to_string()
331        );
332    }
333}