Skip to main content

max / makenotwork

4.4 KB · 118 lines History Blame Raw
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 // Client errors carry their (already user-facing) message; server errors
22 // are logged in full but return a generic body so internal detail (SQL
23 // text, anyhow chains, file paths) never leaks to the client. Every
24 // response is the JSON envelope `{"error": <message>}`, matching Bento
25 // and the 400/404/409/500 shape the endpoints already speak.
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 /// A JSON request body that funnels deserialization failures through the daemon
52 /// `Error` envelope instead of axum's raw `422 Unprocessable Entity`. A bare
53 /// `Json<T>` extractor rejects with axum's own plain-text 422, which is outside
54 /// the 400/404/409/500 contract every other endpoint speaks; `TypedBody<T>` maps
55 /// any rejection to `Error::BadRequest` (400) so a non-TUI driver gets one
56 /// consistent error shape. Use this for every handler that takes a required JSON
57 /// body.
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 /// A server error is a generic body, never the underlying SQL text or
105 /// anyhow chain.
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