use std::{collections::BTreeMap, error::Error as StdError, fmt};
use axum::{
Json,
extract::rejection::{JsonRejection, PathRejection, QueryRejection},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::RETRY_AFTER},
response::{IntoResponse, Response},
};
use baukit_core::pagination::PaginationError;
use serde_json::Value;
use crate::{
ErrorBody, ErrorEnvelope,
middleware::current_request_id,
options::{JsonRejectionCodes, JsonRejectionMode},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum JsonRejectionClass {
BodyTooLarge,
ContentType,
Syntax,
DataShape,
}
impl JsonRejectionClass {
fn from_rejection(rejection: &JsonRejection) -> Self {
match rejection {
JsonRejection::JsonDataError(_) => Self::DataShape,
JsonRejection::JsonSyntaxError(_) => Self::Syntax,
JsonRejection::MissingJsonContentType(_) => Self::ContentType,
JsonRejection::BytesRejection(_) => {
if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
Self::BodyTooLarge
} else {
Self::Syntax
}
}
_ if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => Self::BodyTooLarge,
_ if rejection.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => Self::ContentType,
_ if rejection.status() == StatusCode::UNPROCESSABLE_ENTITY => Self::DataShape,
_ => Self::Syntax,
}
}
}
pub struct ApiError {
status: StatusCode,
code: String,
message: String,
details: BTreeMap<String, Value>,
headers: Option<Box<HeaderMap>>,
cause: Option<Box<dyn StdError + Send + Sync>>,
}
impl ApiError {
pub fn new(status: StatusCode, code: impl Into<String>, message: impl Into<String>) -> Self {
let code = code.into();
assert!(
is_valid_error_code(&code),
"API error codes must be non-empty snake_case identifiers"
);
Self {
status,
code,
message: message.into(),
details: BTreeMap::new(),
headers: None,
cause: None,
}
}
#[must_use]
pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
self.details = details;
self
}
#[must_use]
pub fn with_header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers
.get_or_insert_with(Default::default)
.insert(name, value);
self
}
#[must_use]
pub fn with_retry_after(self, seconds: u64) -> Self {
self.with_header(
RETRY_AFTER,
HeaderValue::from_str(&seconds.to_string())
.expect("unsigned integers are valid header values"),
)
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub fn validation(details: BTreeMap<String, Value>) -> Self {
Self::new(
StatusCode::BAD_REQUEST,
"validation_failed",
"The request is invalid",
)
.with_details(details)
}
pub fn validation_field(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::validation(BTreeMap::from([(
field.into(),
Value::String(message.into()),
)]))
}
pub fn validation_fields<K, M, I>(fields: I) -> Self
where
K: Into<String>,
M: Into<String>,
I: IntoIterator<Item = (K, M)>,
{
Self::validation(
fields
.into_iter()
.map(|(field, message)| (field.into(), Value::String(message.into())))
.collect(),
)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
}
pub fn unauthenticated() -> Self {
Self::new(
StatusCode::UNAUTHORIZED,
"unauthenticated",
"Authentication is required",
)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, "forbidden", message)
}
pub fn permission_denied() -> Self {
Self::new(
StatusCode::FORBIDDEN,
"permission_denied",
"Permission denied",
)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found", message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, "conflict", message)
}
pub fn method_not_allowed() -> Self {
Self::new(
StatusCode::METHOD_NOT_ALLOWED,
"method_not_allowed",
"Method not allowed",
)
}
pub fn rate_limited() -> Self {
Self::new(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"Too many requests",
)
}
pub fn internal(error: impl StdError + Send + Sync + 'static) -> Self {
let mut api_error = Self::internal_without_cause();
api_error.cause = Some(Box::new(error));
api_error
}
pub(crate) fn timeout() -> Self {
Self::new(
StatusCode::GATEWAY_TIMEOUT,
"request_timeout",
"The request timed out",
)
}
pub(crate) fn payload_too_large() -> Self {
Self::body_too_large("payload_too_large")
}
pub(crate) fn configured_payload_too_large(mode: &JsonRejectionMode) -> Self {
match mode {
JsonRejectionMode::Legacy(_) => Self::payload_too_large(),
JsonRejectionMode::Classified(codes) => Self::body_too_large(codes.body_too_large()),
}
}
pub(crate) fn json_rejection(rejection: &JsonRejection, mode: &JsonRejectionMode) -> Self {
match mode {
JsonRejectionMode::Legacy(code) => Self::legacy_json_rejection(code),
JsonRejectionMode::Classified(codes) => {
Self::classified_json_rejection(rejection, codes)
}
}
}
fn body_too_large(code: impl Into<String>) -> Self {
Self::new(
StatusCode::PAYLOAD_TOO_LARGE,
code,
"The request body is too large",
)
}
fn legacy_json_rejection(code: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, code, "The request is invalid").with_details(
BTreeMap::from([(
"body".to_owned(),
Value::String("must be valid JSON matching the request schema".to_owned()),
)]),
)
}
fn classified_json_rejection(rejection: &JsonRejection, codes: &JsonRejectionCodes) -> Self {
match JsonRejectionClass::from_rejection(rejection) {
JsonRejectionClass::BodyTooLarge => Self::body_too_large(codes.body_too_large()),
JsonRejectionClass::ContentType => Self::new(
rejection.status(),
codes.content_type(),
"The request content type is unsupported",
),
JsonRejectionClass::Syntax => {
Self::new(rejection.status(), codes.syntax(), "The request is invalid")
.with_details(BTreeMap::from([(
"body".to_owned(),
Value::String("must contain valid JSON".to_owned()),
)]))
}
JsonRejectionClass::DataShape => Self::new(
rejection.status(),
codes.data_shape(),
"The request is invalid",
)
.with_details(BTreeMap::from([(
"body".to_owned(),
Value::String("must match the request schema".to_owned()),
)])),
}
}
pub(crate) fn query_rejection() -> Self {
Self::validation_field("query", "must contain valid query parameters")
}
pub(crate) fn internal_without_cause() -> Self {
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
"An internal error occurred",
)
}
#[must_use]
pub const fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn code(&self) -> &str {
&self.code
}
}
impl From<JsonRejection> for ApiError {
fn from(_rejection: JsonRejection) -> Self {
Self::legacy_json_rejection("validation_failed")
}
}
impl From<PathRejection> for ApiError {
fn from(_rejection: PathRejection) -> Self {
Self::validation(BTreeMap::from([(
"path".to_owned(),
Value::String("must contain valid route parameters".to_owned()),
)]))
}
}
impl From<QueryRejection> for ApiError {
fn from(_rejection: QueryRejection) -> Self {
Self::query_rejection()
}
}
impl From<PaginationError> for ApiError {
fn from(error: PaginationError) -> Self {
match error {
PaginationError::InvalidLimit => Self::validation_field("limit", error.to_string()),
PaginationError::InvalidCursor => Self::validation_field("cursor", error.to_string()),
}
}
}
pub(crate) fn is_valid_error_code(code: &str) -> bool {
!code.is_empty()
&& !code.starts_with('_')
&& !code.ends_with('_')
&& !code.contains("__")
&& code
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}
impl fmt::Debug for ApiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ApiError")
.field("status", &self.status)
.field("code", &self.code)
.field("message", &self.message)
.field("details", &self.details)
.field("headers", &self.headers)
.field("has_cause", &self.cause.is_some())
.finish()
}
}
impl fmt::Display for ApiError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{} ({})", self.message, self.code)
}
}
impl StdError for ApiError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.cause
.as_deref()
.map(|cause| cause as &(dyn StdError + 'static))
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let request_id = current_request_id();
if let Some(cause) = self.cause.as_deref() {
tracing::error!(error = %cause, "request failed with an internal error");
}
let envelope = ErrorEnvelope {
error: ErrorBody {
code: self.code,
message: self.message,
request_id: request_id.to_string(),
details: self.details,
},
};
let mut response = (self.status, Json(envelope)).into_response();
if let Some(headers) = self.headers {
response.headers_mut().extend(*headers);
}
response.headers_mut().insert(
crate::X_REQUEST_ID,
request_id
.header_value()
.expect("request IDs originate from a valid header or UUID"),
);
response
}
}