use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Clone, Copy)]
pub enum AppErrorKind {
Unauthorized,
BadRequest,
NotFound,
Conflict,
Internal,
PayloadTooLarge,
UnsupportedMediaType,
MethodNotAllowed,
ServiceUnavailable,
FormationCycle,
FormationMultipleCoordinators,
FormationNoCoordinator,
FormationAuthorityNotNarrowing,
}
impl AppErrorKind {
pub fn status(self) -> StatusCode {
match self {
AppErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
AppErrorKind::BadRequest
| AppErrorKind::FormationCycle
| AppErrorKind::FormationMultipleCoordinators
| AppErrorKind::FormationNoCoordinator
| AppErrorKind::FormationAuthorityNotNarrowing => StatusCode::BAD_REQUEST,
AppErrorKind::NotFound => StatusCode::NOT_FOUND,
AppErrorKind::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
AppErrorKind::Conflict => StatusCode::CONFLICT,
AppErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
AppErrorKind::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
AppErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
AppErrorKind::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
}
}
pub fn type_uri(self) -> &'static str {
match self {
AppErrorKind::Unauthorized => "/problems/unauthorized",
AppErrorKind::BadRequest => "/problems/bad-request",
AppErrorKind::NotFound => "/problems/not-found",
AppErrorKind::Conflict => "/problems/conflict",
AppErrorKind::Internal => "/problems/internal",
AppErrorKind::PayloadTooLarge => "/problems/payload-too-large",
AppErrorKind::UnsupportedMediaType => "/problems/unsupported-media-type",
AppErrorKind::MethodNotAllowed => "/problems/method-not-allowed",
AppErrorKind::ServiceUnavailable => "/problems/service-unavailable",
AppErrorKind::FormationCycle => "/problems/formation/cycle",
AppErrorKind::FormationMultipleCoordinators => {
"/problems/formation/multiple-coordinators"
}
AppErrorKind::FormationNoCoordinator => "/problems/formation/no-coordinator",
AppErrorKind::FormationAuthorityNotNarrowing => {
"/problems/formation/authority-not-narrowing"
}
}
}
pub fn title(self) -> &'static str {
match self {
AppErrorKind::Unauthorized => "Unauthorized",
AppErrorKind::BadRequest => "Bad Request",
AppErrorKind::NotFound => "Not Found",
AppErrorKind::Conflict => "Conflict",
AppErrorKind::Internal => "Internal Server Error",
AppErrorKind::PayloadTooLarge => "Payload Too Large",
AppErrorKind::UnsupportedMediaType => "Unsupported Media Type",
AppErrorKind::MethodNotAllowed => "Method Not Allowed",
AppErrorKind::ServiceUnavailable => "Event store unavailable",
AppErrorKind::FormationCycle => "Formation rejected: authority cycle",
AppErrorKind::FormationMultipleCoordinators => {
"Formation rejected: multiple coordinators"
}
AppErrorKind::FormationNoCoordinator => "Formation rejected: no coordinator",
AppErrorKind::FormationAuthorityNotNarrowing => {
"Formation rejected: authority does not narrow"
}
}
}
}
#[derive(Debug, Clone)]
pub struct AppError {
pub kind: AppErrorKind,
pub detail: String,
}
impl AppError {
pub fn new(kind: AppErrorKind, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
pub fn bad_request(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::BadRequest, detail)
}
pub fn unauthorized(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::Unauthorized, detail)
}
pub fn not_found(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::NotFound, detail)
}
pub fn internal(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::Internal, detail)
}
pub fn payload_too_large(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::PayloadTooLarge, detail)
}
pub fn unsupported_media_type(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::UnsupportedMediaType, detail)
}
pub fn method_not_allowed(detail: impl Into<String>) -> Self {
Self::new(AppErrorKind::MethodNotAllowed, detail)
}
pub fn service_unavailable() -> Self {
Self::new(
AppErrorKind::ServiceUnavailable,
"Event store is temporarily unreachable; retry later",
)
}
}
pub const PROBLEM_JSON_CT: &str = "application/problem+json";
pub fn problem_response(kind: AppErrorKind, detail: impl Into<String>) -> Response {
AppError::new(kind, detail).into_response()
}
#[derive(Debug, Serialize)]
struct ProblemDetails<'a> {
#[serde(rename = "type")]
type_uri: &'a str,
title: &'a str,
status: u16,
detail: &'a str,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.kind.status();
let body = ProblemDetails {
type_uri: self.kind.type_uri(),
title: self.kind.title(),
status: status.as_u16(),
detail: &self.detail,
};
let mut resp = (status, Json(body)).into_response();
resp.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/problem+json"),
);
resp
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::internal(format!("{e:#}"))
}
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self {
AppError::bad_request(format!("invalid json: {e}"))
}
}
pub async fn normalize_problem_response(resp: Response) -> Response {
let status = resp.status();
if !status.is_client_error() {
return resp;
}
let is_problem_json = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|ct| ct.starts_with(PROBLEM_JSON_CT))
.unwrap_or(false);
if is_problem_json {
return resp;
}
let allow_header = resp.headers().get(header::ALLOW).cloned();
let (parts, body) = resp.into_parts();
let detail_bytes = to_bytes(body, 64 * 1024).await.unwrap_or_default();
let detail = std::str::from_utf8(&detail_bytes)
.unwrap_or("")
.trim()
.to_string();
let kind = match status {
StatusCode::BAD_REQUEST => AppErrorKind::BadRequest,
StatusCode::UNAUTHORIZED => AppErrorKind::Unauthorized,
StatusCode::NOT_FOUND => AppErrorKind::NotFound,
StatusCode::METHOD_NOT_ALLOWED => AppErrorKind::MethodNotAllowed,
StatusCode::CONFLICT => AppErrorKind::Conflict,
StatusCode::PAYLOAD_TOO_LARGE => AppErrorKind::PayloadTooLarge,
StatusCode::UNSUPPORTED_MEDIA_TYPE => AppErrorKind::UnsupportedMediaType,
_ => AppErrorKind::BadRequest,
};
let detail = if detail.is_empty() {
match status {
StatusCode::NOT_FOUND => "no route matched the request path".to_string(),
StatusCode::METHOD_NOT_ALLOWED => "HTTP method not allowed for this path".to_string(),
StatusCode::PAYLOAD_TOO_LARGE => "request body exceeds the per-route cap".to_string(),
_ => parts
.status
.canonical_reason()
.unwrap_or("client error")
.to_string(),
}
} else {
detail
};
let body = ProblemDetails {
type_uri: kind.type_uri(),
title: kind.title(),
status: status.as_u16(),
detail: &detail,
};
let body_bytes = serde_json::to_vec(&body)
.unwrap_or_else(|_| br#"{"type":"/problems/internal","title":"Internal Server Error","status":500,"detail":"failed to serialise problem document"}"#.to_vec());
let mut new = Response::builder()
.status(status)
.body(Body::from(body_bytes))
.expect("problem+json response build");
for (name, value) in parts.headers.iter() {
if name == header::CONTENT_TYPE || name == header::CONTENT_LENGTH {
continue;
}
new.headers_mut().append(name.clone(), value.clone());
}
new.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(PROBLEM_JSON_CT),
);
if let Some(v) = allow_header {
new.headers_mut().insert(header::ALLOW, v);
}
new
}