use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
pub struct Error {
status: StatusCode,
message: String,
}
impl Error {
pub(crate) fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
pub(crate) fn not_found(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: msg.into(),
}
}
pub(crate) fn conflict(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: msg.into(),
}
}
pub(crate) fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: msg.into(),
}
}
pub(crate) fn locked() -> Self {
Self::internal("server state lock poisoned")
}
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
(
self.status,
Json(json!({
"schema": "mnem.v1.err",
"error": self.message,
})),
)
.into_response()
}
}
impl From<anyhow::Error> for Error {
fn from(e: anyhow::Error) -> Self {
Self::internal(format!("{e:#}"))
}
}
pub(crate) async fn json_rejection_envelope(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Response {
use axum::body::to_bytes;
use axum::http::header::CONTENT_TYPE;
let path = req.uri().path();
let is_remote_problem_json =
path == "/remote/v1/push-blocks" || path == "/remote/v1/advance-head";
let response = next.run(req).await;
let trigger = matches!(
response.status(),
StatusCode::BAD_REQUEST
| StatusCode::UNSUPPORTED_MEDIA_TYPE
| StatusCode::UNPROCESSABLE_ENTITY
);
if !trigger || is_remote_problem_json {
return response;
}
let is_text = response
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|s| s.starts_with("text/"));
if !is_text {
return response;
}
let (parts, body) = response.into_parts();
let bytes = match to_bytes(body, 64 * 1024).await {
Ok(b) => b,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"schema": "mnem.v1.err",
"error": "request body could not be parsed",
})),
)
.into_response();
}
};
let msg = String::from_utf8_lossy(&bytes).into_owned();
let _ = parts; (
StatusCode::BAD_REQUEST,
Json(json!({
"schema": "mnem.v1.err",
"error": format!("invalid request body: {msg}"),
})),
)
.into_response()
}
#[derive(Debug)]
pub enum RemoteError {
BadRequest(String),
NotFound(String),
CasMismatch {
current: mnem_core::id::Cid,
},
Internal(String),
}
impl RemoteError {
fn status(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::CasMismatch { .. } => StatusCode::CONFLICT,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn title(&self) -> &'static str {
match self {
Self::BadRequest(_) => "Bad Request",
Self::NotFound(_) => "Not Found",
Self::CasMismatch { .. } => "Conflict",
Self::Internal(_) => "Internal Server Error",
}
}
fn type_uri(&self) -> &'static str {
match self {
Self::BadRequest(_) => "https://mnem.dev/errors/remote/bad-request",
Self::NotFound(_) => "https://mnem.dev/errors/remote/not-found",
Self::CasMismatch { .. } => "https://mnem.dev/errors/remote/cas-mismatch",
Self::Internal(_) => "https://mnem.dev/errors/remote/internal",
}
}
fn detail(&self) -> String {
match self {
Self::BadRequest(m) | Self::NotFound(m) | Self::Internal(m) => m.clone(),
Self::CasMismatch { current } => {
format!("ref moved under caller; current head is {current}")
}
}
}
}
impl IntoResponse for RemoteError {
fn into_response(self) -> Response {
let status = self.status();
let mut body = json!({
"type": self.type_uri(),
"title": self.title(),
"status": status.as_u16(),
"detail": self.detail(),
});
if let Self::CasMismatch { current } = &self {
body["current"] = json!(current.to_string());
}
(
status,
[(axum::http::header::CONTENT_TYPE, "application/problem+json")],
body.to_string(),
)
.into_response()
}
}
impl From<mnem_core::Error> for Error {
fn from(e: mnem_core::Error) -> Self {
use mnem_core::Error as CoreError;
use mnem_core::RepoError;
let msg = format!("{e}");
let status = match &e {
CoreError::Repo(RepoError::NotFound) => StatusCode::NOT_FOUND,
CoreError::Repo(RepoError::AmbiguousMatch | RepoError::Stale) => StatusCode::CONFLICT,
CoreError::Repo(RepoError::Uninitialized) => StatusCode::SERVICE_UNAVAILABLE,
CoreError::Repo(RepoError::VectorDimMismatch { .. } | RepoError::RetrievalEmpty) => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
Self {
status,
message: msg,
}
}
}
#[cfg(test)]
mod remote_error_tests {
use super::*;
use mnem_core::id::Cid;
fn raw_cid(byte: u8) -> Cid {
let mh = mnem_core::id::Multihash::sha2_256(&[byte]);
Cid::new(mnem_core::id::CODEC_RAW, mh)
}
fn status_of(e: RemoteError) -> u16 {
e.into_response().status().as_u16()
}
#[test]
fn bad_request_maps_to_400() {
assert_eq!(status_of(RemoteError::BadRequest("bad".into())), 400);
}
#[test]
fn not_found_maps_to_404() {
assert_eq!(status_of(RemoteError::NotFound("nope".into())), 404);
}
#[test]
fn cas_mismatch_maps_to_409() {
let e = RemoteError::CasMismatch {
current: raw_cid(7),
};
assert_eq!(status_of(e), 409);
}
#[test]
fn internal_maps_to_500() {
assert_eq!(status_of(RemoteError::Internal("boom".into())), 500);
}
#[test]
fn cas_mismatch_body_carries_current_cid() {
let cid = raw_cid(42);
let e = RemoteError::CasMismatch {
current: cid.clone(),
};
let resp = e.into_response();
assert_eq!(resp.status().as_u16(), 409);
let e2 = RemoteError::CasMismatch {
current: cid.clone(),
};
let json = serde_json::json!({
"type": e2.type_uri(),
"title": e2.title(),
"status": 409,
"detail": e2.detail(),
"current": cid.to_string(),
});
assert_eq!(json["current"], cid.to_string());
assert_eq!(json["status"], 409);
}
}