| 1 |
use axum::extract::FromRequest; |
| 2 |
use axum::http::StatusCode; |
| 3 |
use axum::response::{IntoResponse, Response}; |
| 4 |
|
| 5 |
#[derive(Debug, thiserror::Error)] |
| 6 |
pub enum Error { |
| 7 |
#[error("not found")] |
| 8 |
NotFound, |
| 9 |
#[error("bad request: {0}")] |
| 10 |
BadRequest(String), |
| 11 |
#[error("gate not satisfied: {0}")] |
| 12 |
GateBlocked(String), |
| 13 |
#[error(transparent)] |
| 14 |
Db(#[from] sqlx::Error), |
| 15 |
#[error(transparent)] |
| 16 |
Other(#[from] anyhow::Error), |
| 17 |
} |
| 18 |
|
| 19 |
impl IntoResponse for Error { |
| 20 |
fn into_response(self) -> Response { |
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
let (status, message) = match &self { |
| 27 |
Error::NotFound => (StatusCode::NOT_FOUND, self.to_string()), |
| 28 |
Error::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), |
| 29 |
Error::GateBlocked(_) => (StatusCode::CONFLICT, self.to_string()), |
| 30 |
Error::Db(e) => { |
| 31 |
tracing::error!(error = %e, "internal db error"); |
| 32 |
( |
| 33 |
StatusCode::INTERNAL_SERVER_ERROR, |
| 34 |
"internal server error".to_string(), |
| 35 |
) |
| 36 |
} |
| 37 |
Error::Other(e) => { |
| 38 |
tracing::error!(error = format!("{e:#}"), "internal error"); |
| 39 |
( |
| 40 |
StatusCode::INTERNAL_SERVER_ERROR, |
| 41 |
"internal server error".to_string(), |
| 42 |
) |
| 43 |
} |
| 44 |
}; |
| 45 |
(status, axum::Json(serde_json::json!({ "error": message }))).into_response() |
| 46 |
} |
| 47 |
} |
| 48 |
|
| 49 |
pub type Result<T> = std::result::Result<T, Error>; |
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
|
| 57 |
|
| 58 |
pub struct TypedBody<T>(pub T); |
| 59 |
|
| 60 |
impl<T, S> FromRequest<S> for TypedBody<T> |
| 61 |
where |
| 62 |
T: serde::de::DeserializeOwned, |
| 63 |
S: Send + Sync, |
| 64 |
{ |
| 65 |
type Rejection = Error; |
| 66 |
|
| 67 |
async fn from_request( |
| 68 |
req: axum::extract::Request, |
| 69 |
state: &S, |
| 70 |
) -> std::result::Result<Self, Self::Rejection> { |
| 71 |
let axum::Json(value) = axum::Json::<T>::from_request(req, state) |
| 72 |
.await |
| 73 |
.map_err(|rej| { |
| 74 |
Error::BadRequest(format!("invalid request body: {}", rej.body_text())) |
| 75 |
})?; |
| 76 |
Ok(TypedBody(value)) |
| 77 |
} |
| 78 |
} |
| 79 |
|
| 80 |
#[cfg(test)] |
| 81 |
mod tests { |
| 82 |
use super::*; |
| 83 |
use axum::response::IntoResponse; |
| 84 |
use http_body_util::BodyExt; |
| 85 |
|
| 86 |
async fn parts(err: Error) -> (StatusCode, serde_json::Value) { |
| 87 |
let resp = err.into_response(); |
| 88 |
let status = resp.status(); |
| 89 |
let bytes = resp.into_body().collect().await.unwrap().to_bytes(); |
| 90 |
(status, serde_json::from_slice(&bytes).unwrap()) |
| 91 |
} |
| 92 |
|
| 93 |
#[tokio::test] |
| 94 |
async fn client_errors_keep_their_message_in_a_json_envelope() { |
| 95 |
let (status, body) = parts(Error::BadRequest("no version specified".into())).await; |
| 96 |
assert_eq!(status, StatusCode::BAD_REQUEST); |
| 97 |
assert_eq!(body["error"], "bad request: no version specified"); |
| 98 |
|
| 99 |
let (status, body) = parts(Error::GateBlocked("boot_smoke red".into())).await; |
| 100 |
assert_eq!(status, StatusCode::CONFLICT); |
| 101 |
assert_eq!(body["error"], "gate not satisfied: boot_smoke red"); |
| 102 |
} |
| 103 |
|
| 104 |
|
| 105 |
|
| 106 |
#[tokio::test] |
| 107 |
async fn server_errors_do_not_leak_internal_detail() { |
| 108 |
let secret = "no such column: super_secret_internal_table.token"; |
| 109 |
let (status, body) = parts(Error::Other(anyhow::anyhow!(secret))).await; |
| 110 |
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); |
| 111 |
assert_eq!(body["error"], "internal server error"); |
| 112 |
assert!( |
| 113 |
!body.to_string().contains("super_secret"), |
| 114 |
"internal detail must not reach the client: {body}" |
| 115 |
); |
| 116 |
} |
| 117 |
} |
| 118 |
|