use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use crate::concept_map::ConceptMapMutationError;
#[derive(Debug, thiserror::Error)]
pub(crate) enum MapServerError {
#[error("bad entity id: {0}")]
BadEntityId(String),
#[error("entity not found: {0}")]
EntityNotFound(String),
#[error("asset not found: {0}")]
AssetNotFound(String),
#[error("entity markdown not implemented for kind: {0}")]
MarkdownNotImplemented(&'static str),
#[error("request body too large")]
BodyTooLarge,
#[error("{tool} is unavailable")]
ToolUnavailable { tool: &'static str },
#[error("{command} failed with status {status:?}")]
CommandFailed {
command: &'static str,
status: Option<i32>,
stderr: String,
},
#[error("{command} timed out")]
Timeout { command: &'static str },
#[error("bad concept-map id: {0}")]
BadConceptMapId(String),
#[error("concept map not found: CM-{0:03}")]
ConceptMapNotFound(u32),
#[error("duplicate edge at line {line}")]
DuplicateEdge { line: usize },
#[error("edge not found")]
EdgeNotFound,
#[error("rename would collide with existing node '{0}' at line {1}")]
NodeCollision(String, usize),
#[error("empty field: {0}")]
EmptyField(String),
#[error("concept map was modified since last read")]
StaleConceptMap,
#[error("concept-map parse error: {0}")]
ConceptMapParseError(String),
#[error("concept-map I/O error: {0}")]
ConceptMapIoError(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
const STDERR_CAP: usize = 8 * 1024;
fn truncate_stderr(stderr: &str) -> &str {
if stderr.len() <= STDERR_CAP {
return stderr;
}
match stderr.char_indices().nth(STDERR_CAP) {
Some((idx, _)) => &stderr[..idx],
None => stderr,
}
}
impl IntoResponse for MapServerError {
fn into_response(self) -> axum::response::Response {
let body: serde_json::Value = match &self {
Self::BadEntityId(id) => {
serde_json::json!({
"error": "bad_entity_id",
"message": format!("bad entity id: {id}"),
})
}
Self::EntityNotFound(id) => {
serde_json::json!({
"error": "entity_not_found",
"message": format!("entity not found: {id}"),
})
}
Self::AssetNotFound(id) => {
serde_json::json!({
"error": "asset_not_found",
"message": format!("asset not found: {id}"),
})
}
Self::MarkdownNotImplemented(kind) => {
serde_json::json!({
"error": "markdown_not_implemented",
"message": format!("entity markdown not implemented for kind: {kind}"),
})
}
Self::BodyTooLarge => {
serde_json::json!({
"error": "body_too_large",
"message": "request body too large",
})
}
Self::ToolUnavailable { tool } => {
serde_json::json!({
"error": "tool_unavailable",
"message": format!("{tool} is unavailable"),
"tool": tool,
})
}
Self::CommandFailed {
command,
status,
stderr,
} => {
serde_json::json!({
"error": "command_failed",
"message": format!("{command} failed with status {status:?}"),
"command": command,
"status": status,
"stderr": truncate_stderr(stderr),
})
}
Self::Timeout { command } => {
serde_json::json!({
"error": "timeout",
"message": format!("{command} timed out"),
"command": command,
})
}
Self::BadConceptMapId(id) => {
serde_json::json!({
"error": "bad_concept_map_id",
"message": format!("bad concept-map id: {id}"),
})
}
Self::ConceptMapNotFound(id) => {
serde_json::json!({
"error": "not_found",
"message": format!("concept map CM-{id:03} not found"),
})
}
Self::DuplicateEdge { line } => {
serde_json::json!({
"error": "duplicate_edge",
"message": format!("edge already exists at line {line}"),
"line": line,
})
}
Self::EdgeNotFound => {
serde_json::json!({
"error": "edge_not_found",
"message": "edge not found",
})
}
Self::NodeCollision(existing_label, line) => {
serde_json::json!({
"error": "node_collision",
"message": format!("rename would collide with existing node '{existing_label}' at line {line}"),
"existing_label": existing_label,
"line": line,
})
}
Self::EmptyField(field) => {
serde_json::json!({
"error": "empty_field",
"message": format!("{field} must be non-empty"),
})
}
Self::StaleConceptMap => {
serde_json::json!({
"error": "stale_concept_map",
"message": "concept map was modified since last read; refresh and retry",
})
}
Self::ConceptMapParseError(msg) => {
serde_json::json!({
"error": "concept_map_parse_error",
"message": msg,
})
}
Self::ConceptMapIoError(msg) => {
serde_json::json!({
"error": "concept_map_io_error",
"message": msg,
})
}
Self::Other(err) => {
serde_json::json!({
"error": "other",
"message": format!("{err:#}"),
})
}
};
let status = status_code(&self);
(status, Json(body)).into_response()
}
}
fn status_code(err: &MapServerError) -> StatusCode {
match err {
MapServerError::BadEntityId(_)
| MapServerError::BadConceptMapId(_)
| MapServerError::EmptyField(_) => StatusCode::BAD_REQUEST,
MapServerError::EntityNotFound(_)
| MapServerError::AssetNotFound(_)
| MapServerError::ConceptMapNotFound(_)
| MapServerError::EdgeNotFound => StatusCode::NOT_FOUND,
MapServerError::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
MapServerError::CommandFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY,
MapServerError::DuplicateEdge { .. }
| MapServerError::NodeCollision(..)
| MapServerError::StaleConceptMap => StatusCode::CONFLICT,
MapServerError::MarkdownNotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
MapServerError::ToolUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
MapServerError::Timeout { .. } => StatusCode::GATEWAY_TIMEOUT,
MapServerError::ConceptMapParseError(_)
| MapServerError::ConceptMapIoError(_)
| MapServerError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
impl From<ConceptMapMutationError> for MapServerError {
fn from(e: ConceptMapMutationError) -> Self {
match e {
ConceptMapMutationError::EmptyField(field) => Self::EmptyField(field),
ConceptMapMutationError::DuplicateEdge { line } => Self::DuplicateEdge { line },
ConceptMapMutationError::EdgeNotFound => Self::EdgeNotFound,
ConceptMapMutationError::NodeCollision {
existing_label,
line,
} => Self::NodeCollision(existing_label, line),
ConceptMapMutationError::MissingDsl => {
Self::ConceptMapParseError("TOML is missing a `dsl` key".into())
}
ConceptMapMutationError::InvalidToml(msg) => Self::ConceptMapParseError(msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use serde::Deserialize;
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
message: String,
#[serde(default)]
command: Option<String>,
#[serde(default)]
status: Option<i32>,
#[serde(default)]
stderr: Option<String>,
#[serde(default)]
tool: Option<String>,
}
async fn into_error_body(response: axum::response::Response) -> (StatusCode, ErrorResponse) {
let status = response.status();
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let body: ErrorResponse = serde_json::from_slice(&bytes).unwrap();
(status, body)
}
#[tokio::test]
async fn bad_entity_id_400() {
let err = MapServerError::BadEntityId("garbage".into());
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body.error, "bad_entity_id");
assert!(body.message.contains("garbage"));
}
#[tokio::test]
async fn entity_not_found_404() {
let err = MapServerError::EntityNotFound("SL-999".into());
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.error, "entity_not_found");
assert!(body.message.contains("SL-999"));
}
#[tokio::test]
async fn asset_not_found_404() {
let err = MapServerError::AssetNotFound("icon.png".into());
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body.error, "asset_not_found");
assert!(body.message.contains("icon.png"));
}
#[tokio::test]
async fn markdown_not_implemented_501() {
let err = MapServerError::MarkdownNotImplemented("slice");
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
assert_eq!(body.error, "markdown_not_implemented");
assert!(body.message.contains("slice"));
}
#[tokio::test]
async fn body_too_large_413() {
let err = MapServerError::BodyTooLarge;
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(body.error, "body_too_large");
}
#[tokio::test]
async fn tool_unavailable_503() {
let err = MapServerError::ToolUnavailable { tool: "graphviz" };
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body.error, "tool_unavailable");
assert_eq!(body.tool.as_deref(), Some("graphviz"));
}
#[tokio::test]
async fn command_failed_422() {
let err = MapServerError::CommandFailed {
command: "dot",
status: Some(1),
stderr: "syntax error".into(),
};
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body.error, "command_failed");
assert_eq!(body.command.as_deref(), Some("dot"));
assert_eq!(body.status, Some(1));
assert_eq!(body.stderr.as_deref(), Some("syntax error"));
}
#[tokio::test]
async fn timeout_504() {
let err = MapServerError::Timeout { command: "dot" };
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
assert_eq!(body.error, "timeout");
assert_eq!(body.command.as_deref(), Some("dot"));
}
#[tokio::test]
async fn other_500() {
let err = MapServerError::Other(anyhow::anyhow!("bang"));
let (status, body) = into_error_body(err.into_response()).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body.error, "other");
assert!(body.message.contains("bang"));
}
#[tokio::test]
async fn stderr_truncation_at_8kib_boundary() {
let long_stderr = "x".repeat(9 * 1024); let err = MapServerError::CommandFailed {
command: "dot",
status: Some(1),
stderr: long_stderr,
};
let (_status, body) = into_error_body(err.into_response()).await;
let stderr_out = body.stderr.as_deref().unwrap();
assert_eq!(stderr_out.len(), STDERR_CAP);
}
#[tokio::test]
async fn stderr_below_cap_untouched() {
let short = "short error".to_string();
let len = short.len();
let err = MapServerError::CommandFailed {
command: "dot",
status: None,
stderr: short,
};
let (_status, body) = into_error_body(err.into_response()).await;
assert_eq!(body.stderr.as_deref().unwrap().len(), len);
}
}