1use axum::Json;
10use axum::http::StatusCode;
11use axum::response::{IntoResponse, Response};
12use serde_json::json;
13
14pub struct Error {
18 status: StatusCode,
19 message: String,
20}
21
22impl Error {
23 pub(crate) fn bad_request(msg: impl Into<String>) -> Self {
24 Self {
25 status: StatusCode::BAD_REQUEST,
26 message: msg.into(),
27 }
28 }
29
30 pub(crate) fn not_found(msg: impl Into<String>) -> Self {
31 Self {
32 status: StatusCode::NOT_FOUND,
33 message: msg.into(),
34 }
35 }
36
37 pub(crate) fn conflict(msg: impl Into<String>) -> Self {
38 Self {
39 status: StatusCode::CONFLICT,
40 message: msg.into(),
41 }
42 }
43
44 pub(crate) fn internal(msg: impl Into<String>) -> Self {
45 Self {
46 status: StatusCode::INTERNAL_SERVER_ERROR,
47 message: msg.into(),
48 }
49 }
50
51 pub(crate) fn locked() -> Self {
52 Self::internal("server state lock poisoned")
53 }
54}
55
56impl IntoResponse for Error {
57 fn into_response(self) -> Response {
58 (
59 self.status,
60 Json(json!({
61 "schema": "mnem.v1.err",
62 "error": self.message,
63 })),
64 )
65 .into_response()
66 }
67}
68
69impl From<anyhow::Error> for Error {
70 fn from(e: anyhow::Error) -> Self {
71 Self::internal(format!("{e:#}"))
72 }
73}
74
75pub(crate) async fn json_rejection_envelope(
99 req: axum::http::Request<axum::body::Body>,
100 next: axum::middleware::Next,
101) -> Response {
102 use axum::body::to_bytes;
103 use axum::http::header::CONTENT_TYPE;
104
105 let path = req.uri().path();
111 let is_remote_problem_json =
112 path == "/remote/v1/push-blocks" || path == "/remote/v1/advance-head";
113 let response = next.run(req).await;
114
115 let trigger = matches!(
119 response.status(),
120 StatusCode::BAD_REQUEST
121 | StatusCode::UNSUPPORTED_MEDIA_TYPE
122 | StatusCode::UNPROCESSABLE_ENTITY
123 );
124 if !trigger || is_remote_problem_json {
125 return response;
126 }
127 let is_text = response
131 .headers()
132 .get(CONTENT_TYPE)
133 .and_then(|v| v.to_str().ok())
134 .is_some_and(|s| s.starts_with("text/"));
135 if !is_text {
136 return response;
137 }
138 let (parts, body) = response.into_parts();
139 let bytes = match to_bytes(body, 64 * 1024).await {
140 Ok(b) => b,
141 Err(_) => {
142 return (
143 StatusCode::BAD_REQUEST,
144 Json(json!({
145 "schema": "mnem.v1.err",
146 "error": "request body could not be parsed",
147 })),
148 )
149 .into_response();
150 }
151 };
152 let msg = String::from_utf8_lossy(&bytes).into_owned();
153 let _ = parts; (
156 StatusCode::BAD_REQUEST,
157 Json(json!({
158 "schema": "mnem.v1.err",
159 "error": format!("invalid request body: {msg}"),
160 })),
161 )
162 .into_response()
163}
164
165#[derive(Debug)]
171pub enum RemoteError {
172 BadRequest(String),
175 NotFound(String),
177 CasMismatch {
182 current: mnem_core::id::Cid,
184 },
185 Internal(String),
188}
189
190impl RemoteError {
191 fn status(&self) -> StatusCode {
192 match self {
193 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
194 Self::NotFound(_) => StatusCode::NOT_FOUND,
195 Self::CasMismatch { .. } => StatusCode::CONFLICT,
196 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
197 }
198 }
199
200 fn title(&self) -> &'static str {
201 match self {
202 Self::BadRequest(_) => "Bad Request",
203 Self::NotFound(_) => "Not Found",
204 Self::CasMismatch { .. } => "Conflict",
205 Self::Internal(_) => "Internal Server Error",
206 }
207 }
208
209 fn type_uri(&self) -> &'static str {
210 match self {
211 Self::BadRequest(_) => "https://mnem.dev/errors/remote/bad-request",
212 Self::NotFound(_) => "https://mnem.dev/errors/remote/not-found",
213 Self::CasMismatch { .. } => "https://mnem.dev/errors/remote/cas-mismatch",
214 Self::Internal(_) => "https://mnem.dev/errors/remote/internal",
215 }
216 }
217
218 fn detail(&self) -> String {
219 match self {
220 Self::BadRequest(m) | Self::NotFound(m) | Self::Internal(m) => m.clone(),
221 Self::CasMismatch { current } => {
222 format!("ref moved under caller; current head is {current}")
223 }
224 }
225 }
226}
227
228impl IntoResponse for RemoteError {
229 fn into_response(self) -> Response {
230 let status = self.status();
231 let mut body = json!({
232 "type": self.type_uri(),
233 "title": self.title(),
234 "status": status.as_u16(),
235 "detail": self.detail(),
236 });
237 if let Self::CasMismatch { current } = &self {
241 body["current"] = json!(current.to_string());
242 }
243 (
244 status,
245 [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
246 body.to_string(),
247 )
248 .into_response()
249 }
250}
251
252impl From<mnem_core::Error> for Error {
253 fn from(e: mnem_core::Error) -> Self {
254 use mnem_core::Error as CoreError;
263 use mnem_core::RepoError;
264 let msg = format!("{e}");
265 let status = match &e {
266 CoreError::Repo(RepoError::NotFound) => StatusCode::NOT_FOUND,
267 CoreError::Repo(RepoError::AmbiguousMatch | RepoError::Stale) => StatusCode::CONFLICT,
268 CoreError::Repo(RepoError::Uninitialized) => StatusCode::SERVICE_UNAVAILABLE,
269 CoreError::Repo(RepoError::VectorDimMismatch { .. } | RepoError::RetrievalEmpty) => {
270 StatusCode::BAD_REQUEST
271 }
272 _ => StatusCode::INTERNAL_SERVER_ERROR,
273 };
274 Self {
275 status,
276 message: msg,
277 }
278 }
279}
280
281#[cfg(test)]
282mod remote_error_tests {
283 use super::*;
284 use mnem_core::id::Cid;
285
286 fn raw_cid(byte: u8) -> Cid {
287 let mh = mnem_core::id::Multihash::sha2_256(&[byte]);
290 Cid::new(mnem_core::id::CODEC_RAW, mh)
291 }
292
293 fn status_of(e: RemoteError) -> u16 {
294 e.into_response().status().as_u16()
295 }
296
297 #[test]
298 fn bad_request_maps_to_400() {
299 assert_eq!(status_of(RemoteError::BadRequest("bad".into())), 400);
300 }
301
302 #[test]
303 fn not_found_maps_to_404() {
304 assert_eq!(status_of(RemoteError::NotFound("nope".into())), 404);
305 }
306
307 #[test]
308 fn cas_mismatch_maps_to_409() {
309 let e = RemoteError::CasMismatch {
310 current: raw_cid(7),
311 };
312 assert_eq!(status_of(e), 409);
313 }
314
315 #[test]
316 fn internal_maps_to_500() {
317 assert_eq!(status_of(RemoteError::Internal("boom".into())), 500);
318 }
319
320 #[test]
321 fn cas_mismatch_body_carries_current_cid() {
322 let cid = raw_cid(42);
323 let e = RemoteError::CasMismatch {
324 current: cid.clone(),
325 };
326 let resp = e.into_response();
327 assert_eq!(resp.status().as_u16(), 409);
328 let e2 = RemoteError::CasMismatch {
332 current: cid.clone(),
333 };
334 let json = serde_json::json!({
335 "type": e2.type_uri(),
336 "title": e2.title(),
337 "status": 409,
338 "detail": e2.detail(),
339 "current": cid.to_string(),
340 });
341 assert_eq!(json["current"], cid.to_string());
342 assert_eq!(json["status"], 409);
343 }
344}