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 pub(crate) fn status(status: StatusCode, msg: impl Into<String>) -> Self {
57 Self {
58 status,
59 message: msg.into(),
60 }
61 }
62}
63
64impl IntoResponse for Error {
65 fn into_response(self) -> Response {
66 (
67 self.status,
68 Json(json!({
69 "schema": "mnem.v1.err",
70 "error": self.message,
71 })),
72 )
73 .into_response()
74 }
75}
76
77impl From<anyhow::Error> for Error {
78 fn from(e: anyhow::Error) -> Self {
79 Self::internal(format!("{e:#}"))
80 }
81}
82
83pub(crate) async fn json_rejection_envelope(
107 req: axum::http::Request<axum::body::Body>,
108 next: axum::middleware::Next,
109) -> Response {
110 use axum::body::to_bytes;
111 use axum::http::header::CONTENT_TYPE;
112
113 let path = req.uri().path();
119 let is_remote_problem_json =
120 path == "/remote/v1/push-blocks" || path == "/remote/v1/advance-head";
121 let response = next.run(req).await;
122
123 let trigger = matches!(
127 response.status(),
128 StatusCode::BAD_REQUEST
129 | StatusCode::UNSUPPORTED_MEDIA_TYPE
130 | StatusCode::UNPROCESSABLE_ENTITY
131 );
132 if !trigger || is_remote_problem_json {
133 return response;
134 }
135 let is_text = response
139 .headers()
140 .get(CONTENT_TYPE)
141 .and_then(|v| v.to_str().ok())
142 .is_some_and(|s| s.starts_with("text/"));
143 if !is_text {
144 return response;
145 }
146 let (parts, body) = response.into_parts();
147 let bytes = match to_bytes(body, 64 * 1024).await {
148 Ok(b) => b,
149 Err(_) => {
150 return (
151 StatusCode::BAD_REQUEST,
152 Json(json!({
153 "schema": "mnem.v1.err",
154 "error": "request body could not be parsed",
155 })),
156 )
157 .into_response();
158 }
159 };
160 let msg = String::from_utf8_lossy(&bytes).into_owned();
161 let _ = parts; (
164 StatusCode::BAD_REQUEST,
165 Json(json!({
166 "schema": "mnem.v1.err",
167 "error": format!("invalid request body: {msg}"),
168 })),
169 )
170 .into_response()
171}
172
173#[derive(Debug)]
179pub enum RemoteError {
180 BadRequest(String),
183 NotFound(String),
185 CasMismatch {
190 current: mnem_core::id::Cid,
192 },
193 Internal(String),
196}
197
198impl RemoteError {
199 fn status(&self) -> StatusCode {
200 match self {
201 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
202 Self::NotFound(_) => StatusCode::NOT_FOUND,
203 Self::CasMismatch { .. } => StatusCode::CONFLICT,
204 Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
205 }
206 }
207
208 fn title(&self) -> &'static str {
209 match self {
210 Self::BadRequest(_) => "Bad Request",
211 Self::NotFound(_) => "Not Found",
212 Self::CasMismatch { .. } => "Conflict",
213 Self::Internal(_) => "Internal Server Error",
214 }
215 }
216
217 fn type_uri(&self) -> &'static str {
218 match self {
219 Self::BadRequest(_) => "https://mnem.dev/errors/remote/bad-request",
220 Self::NotFound(_) => "https://mnem.dev/errors/remote/not-found",
221 Self::CasMismatch { .. } => "https://mnem.dev/errors/remote/cas-mismatch",
222 Self::Internal(_) => "https://mnem.dev/errors/remote/internal",
223 }
224 }
225
226 fn detail(&self) -> String {
227 match self {
228 Self::BadRequest(m) | Self::NotFound(m) | Self::Internal(m) => m.clone(),
229 Self::CasMismatch { current } => {
230 format!("remote tip has advanced; pull first (current head is {current})")
231 }
232 }
233 }
234}
235
236impl IntoResponse for RemoteError {
237 fn into_response(self) -> Response {
238 let status = self.status();
239 let mut body = json!({
240 "type": self.type_uri(),
241 "title": self.title(),
242 "status": status.as_u16(),
243 "detail": self.detail(),
244 });
245 if let Self::CasMismatch { current } = &self {
250 body["error"] = json!("cas_mismatch");
251 body["current"] = json!(current.to_string());
252 }
253 (
254 status,
255 [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
256 body.to_string(),
257 )
258 .into_response()
259 }
260}
261
262impl From<mnem_core::Error> for Error {
263 fn from(e: mnem_core::Error) -> Self {
264 use mnem_core::Error as CoreError;
273 use mnem_core::RepoError;
274 let msg = format!("{e}");
275 let status = match &e {
276 CoreError::Repo(RepoError::NotFound) => StatusCode::NOT_FOUND,
277 CoreError::Repo(RepoError::AmbiguousMatch | RepoError::Stale) => StatusCode::CONFLICT,
278 CoreError::Repo(RepoError::Uninitialized) => StatusCode::SERVICE_UNAVAILABLE,
279 CoreError::Repo(RepoError::VectorDimMismatch { .. } | RepoError::RetrievalEmpty) => {
280 StatusCode::BAD_REQUEST
281 }
282 CoreError::Repo(RepoError::DanglingEdge { .. }) => StatusCode::UNPROCESSABLE_ENTITY,
285 _ => StatusCode::INTERNAL_SERVER_ERROR,
286 };
287 Self {
288 status,
289 message: msg,
290 }
291 }
292}
293
294#[cfg(test)]
295mod remote_error_tests {
296 use super::*;
297 use mnem_core::id::Cid;
298
299 fn raw_cid(byte: u8) -> Cid {
300 let mh = mnem_core::id::Multihash::sha2_256(&[byte]);
303 Cid::new(mnem_core::id::CODEC_RAW, mh)
304 }
305
306 fn status_of(e: RemoteError) -> u16 {
307 e.into_response().status().as_u16()
308 }
309
310 #[test]
311 fn bad_request_maps_to_400() {
312 assert_eq!(status_of(RemoteError::BadRequest("bad".into())), 400);
313 }
314
315 #[test]
316 fn not_found_maps_to_404() {
317 assert_eq!(status_of(RemoteError::NotFound("nope".into())), 404);
318 }
319
320 #[test]
321 fn cas_mismatch_maps_to_409() {
322 let e = RemoteError::CasMismatch {
323 current: raw_cid(7),
324 };
325 assert_eq!(status_of(e), 409);
326 }
327
328 #[test]
329 fn internal_maps_to_500() {
330 assert_eq!(status_of(RemoteError::Internal("boom".into())), 500);
331 }
332
333 #[test]
334 fn cas_mismatch_body_carries_current_cid() {
335 let cid = raw_cid(42);
336 let e = RemoteError::CasMismatch {
337 current: cid.clone(),
338 };
339 let resp = e.into_response();
340 assert_eq!(resp.status().as_u16(), 409);
341 let e2 = RemoteError::CasMismatch {
345 current: cid.clone(),
346 };
347 let json = serde_json::json!({
348 "type": e2.type_uri(),
349 "title": e2.title(),
350 "status": 409,
351 "detail": e2.detail(),
352 "current": cid.to_string(),
353 });
354 assert_eq!(json["current"], cid.to_string());
355 assert_eq!(json["status"], 409);
356 }
357}