#![allow(clippy::std_instead_of_alloc)]
use std::borrow::Cow;
use axum::{
http::{
header::{ALLOW, CONTENT_TYPE},
HeaderValue, StatusCode,
},
response::{IntoResponse, Response},
};
use docspec_json::{JsonEmitter, StrusonBackend};
#[derive(Debug)]
pub struct ProblemJson {
pub detail: Cow<'static, str>,
pub status: u16,
pub title: &'static str,
pub type_uri: &'static str,
}
impl ProblemJson {
#[inline]
#[must_use]
pub fn to_json_bytes(&self) -> Vec<u8> {
#[allow(clippy::expect_used)]
{
let mut emitter = JsonEmitter::new(StrusonBackend::new(Vec::new()));
emitter
.object(|builder| {
builder.key("type").value(self.type_uri)?;
builder.key("title").value(self.title)?;
builder.key("status").value(u32::from(self.status))?;
builder.key("detail").value(self.detail.as_ref())?;
Ok(())
})
.expect("ProblemJson object emission is infallible");
emitter.finish().expect("ProblemJson finish is infallible")
}
}
}
#[derive(Debug)]
pub enum HttpError {
BodyNotUtf8,
EmptyBody,
Internal,
MethodNotAllowed {
allowed: &'static str,
},
NotAcceptable,
NotFound {
method: String,
path: String,
},
Unprocessable {
detail: String,
},
UnsupportedMediaType {
received: Option<String>,
},
}
impl IntoResponse for HttpError {
#[inline]
fn into_response(self) -> Response {
let (status, title, detail, allow): (
StatusCode,
&'static str,
Cow<'static, str>,
Option<&'static str>,
) = match self {
Self::EmptyBody => (
StatusCode::BAD_REQUEST,
"Bad Request",
Cow::Borrowed("Request body is empty"),
None,
),
Self::BodyNotUtf8 => (
StatusCode::BAD_REQUEST,
"Bad Request",
Cow::Borrowed("Request body is not valid UTF-8"),
None,
),
Self::NotFound { method, path } => (
StatusCode::NOT_FOUND,
"Not Found",
Cow::Owned(format!("No route matches {method} {path}")),
None,
),
Self::MethodNotAllowed { allowed } => (
StatusCode::METHOD_NOT_ALLOWED,
"Method Not Allowed",
Cow::Owned(format!("Method not allowed. Allowed methods: {allowed}.")),
Some(allowed),
),
Self::NotAcceptable => (
StatusCode::NOT_ACCEPTABLE,
"Not Acceptable",
Cow::Borrowed(
"Accept header must include application/vnd.docspec.blocknote+json, \
application/vnd.blocknote+json, application/vnd.oxa+json, text/html, \
application/*, or */*",
),
None,
),
Self::UnsupportedMediaType { received: None } => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
Cow::Borrowed("Content-Type must be text/markdown or text/html"),
None,
),
Self::UnsupportedMediaType {
received: Some(content_type),
} => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Unsupported Media Type",
Cow::Owned(format!(
"Content-Type must be text/markdown or text/html, got {content_type}"
)),
None,
),
Self::Unprocessable { detail } => (
StatusCode::UNPROCESSABLE_ENTITY,
"Unprocessable Entity",
Cow::Owned(detail),
None,
),
Self::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
Cow::Borrowed("An unexpected error occurred during conversion"),
None,
),
};
if status == StatusCode::INTERNAL_SERVER_ERROR || status == StatusCode::UNPROCESSABLE_ENTITY
{
sentry::capture_message(detail.as_ref(), sentry::Level::Error);
}
let body = ProblemJson {
detail,
status: status.as_u16(),
title,
type_uri: "about:blank",
}
.to_json_bytes();
let mut response = (status, body).into_response();
response.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_static("application/problem+json; charset=utf-8"),
);
if let Some(allowed) = allow {
response
.headers_mut()
.insert(ALLOW, HeaderValue::from_static(allowed));
}
response
}
}
impl HttpError {
#[inline]
#[must_use]
pub fn error_class(&self) -> &'static str {
match self {
Self::BodyNotUtf8 => "body_not_utf8",
Self::EmptyBody => "empty_body",
Self::Internal => "internal",
Self::MethodNotAllowed { .. } => "method_not_allowed",
Self::NotAcceptable => "not_acceptable",
Self::NotFound { .. } => "not_found",
Self::Unprocessable { .. } => "unprocessable",
Self::UnsupportedMediaType { .. } => "unsupported_media_type",
}
}
#[inline]
#[must_use]
pub fn result_class(&self) -> &'static str {
use crate::metrics::{RESULT_CLIENT_ERROR, RESULT_SERVER_ERROR};
match self {
Self::BodyNotUtf8
| Self::EmptyBody
| Self::MethodNotAllowed { .. }
| Self::NotAcceptable
| Self::NotFound { .. }
| Self::Unprocessable { .. }
| Self::UnsupportedMediaType { .. } => RESULT_CLIENT_ERROR,
Self::Internal => RESULT_SERVER_ERROR,
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
use axum::{
http::{
header::{ALLOW, CONTENT_TYPE},
StatusCode,
},
response::IntoResponse as _,
};
use super::*;
async fn body_bytes(error: HttpError) -> Vec<u8> {
axum::body::to_bytes(error.into_response().into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
#[test]
fn all_variants_have_correct_status_codes() {
assert_eq!(
HttpError::EmptyBody.into_response().status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
HttpError::BodyNotUtf8.into_response().status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.into_response()
.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }
.into_response()
.status(),
StatusCode::METHOD_NOT_ALLOWED
);
assert_eq!(
HttpError::NotAcceptable.into_response().status(),
StatusCode::NOT_ACCEPTABLE
);
assert_eq!(
HttpError::UnsupportedMediaType { received: None }
.into_response()
.status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.into_response()
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
HttpError::Internal.into_response().status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn method_not_allowed_has_allow_header() {
let response = HttpError::MethodNotAllowed { allowed: "GET" }.into_response();
let allow_val = response.headers().get(ALLOW).unwrap();
assert_eq!(allow_val, "GET");
}
#[test]
fn content_type_is_problem_json() {
let response = HttpError::Internal.into_response();
let content_type = response.headers().get(CONTENT_TYPE).unwrap();
assert_eq!(content_type, "application/problem+json; charset=utf-8");
}
#[test]
fn no_allow_header_on_non_405_variants() {
let response = HttpError::Internal.into_response();
assert!(response.headers().get(ALLOW).is_none());
}
#[test]
fn internal_error_is_captured_by_sentry() {
let events = sentry::test::with_captured_events(|| {
let _response = HttpError::Internal.into_response();
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].level, sentry::Level::Error);
assert_eq!(
events[0].message.as_deref(),
Some("An unexpected error occurred during conversion")
);
}
#[test]
fn unprocessable_error_is_captured_by_sentry() {
let events = sentry::test::with_captured_events(|| {
let _response = HttpError::Unprocessable {
detail: "bad input".to_owned(),
}
.into_response();
});
assert_eq!(events.len(), 1);
assert_eq!(events[0].level, sentry::Level::Error);
assert_eq!(events[0].message.as_deref(), Some("bad input"));
}
#[test]
fn client_errors_are_not_captured_by_sentry() {
let events = sentry::test::with_captured_events(|| {
drop(HttpError::EmptyBody.into_response());
drop(HttpError::BodyNotUtf8.into_response());
drop(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/x".to_owned(),
}
.into_response(),
);
drop(HttpError::MethodNotAllowed { allowed: "GET" }.into_response());
drop(HttpError::NotAcceptable.into_response());
drop(HttpError::UnsupportedMediaType { received: None }.into_response());
});
assert_eq!(events.len(), 0, "4xx errors must not be captured");
}
#[tokio::test]
async fn serializes_with_four_fields() {
let bytes = body_bytes(HttpError::Internal).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred during conversion",
})
);
}
#[tokio::test]
async fn no_instance_key_in_output() {
let bytes = body_bytes(HttpError::EmptyBody).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
json.get("instance").is_none(),
"unexpected 'instance' key in output"
);
}
#[tokio::test]
async fn not_found_problem_body_is_exact() {
let bytes = body_bytes(HttpError::NotFound {
method: "GET".to_owned(),
path: "/api/v99".to_owned(),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No route matches GET /api/v99",
})
);
}
#[tokio::test]
async fn internal_detail_is_fixed() {
let bytes = body_bytes(HttpError::Internal).await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json["detail"].as_str().unwrap(),
"An unexpected error occurred during conversion"
);
}
#[tokio::test]
async fn unsupported_media_type_with_received_problem_body_is_exact() {
let bytes = body_bytes(HttpError::UnsupportedMediaType {
received: Some("application/json".to_owned()),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Unsupported Media Type",
"status": 415,
"detail": "Content-Type must be text/markdown or text/html, got application/json",
})
);
}
#[tokio::test]
async fn unprocessable_problem_body_is_exact() {
let message = "heading level jumped from 1 to 3".to_owned();
let bytes = body_bytes(HttpError::Unprocessable {
detail: message.clone(),
})
.await;
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": message,
})
);
}
#[tokio::test]
async fn control_char_in_detail_is_escaped() {
let bytes = body_bytes(HttpError::Unprocessable {
detail: "bad\x01input".to_owned(),
})
.await;
assert_eq!(
bytes.as_slice(),
br#"{"type":"about:blank","title":"Unprocessable Entity","status":422,"detail":"bad\u0001input"}"#
);
}
#[test]
fn body_not_utf8_error_class_returns_body_not_utf8() {
assert_eq!(HttpError::BodyNotUtf8.error_class(), "body_not_utf8");
}
#[test]
fn empty_body_error_class_returns_empty_body() {
assert_eq!(HttpError::EmptyBody.error_class(), "empty_body");
}
#[test]
fn internal_error_class_returns_internal() {
assert_eq!(HttpError::Internal.error_class(), "internal");
}
#[test]
fn method_not_allowed_error_class_returns_method_not_allowed() {
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }.error_class(),
"method_not_allowed"
);
}
#[test]
fn not_acceptable_error_class_returns_not_acceptable() {
assert_eq!(HttpError::NotAcceptable.error_class(), "not_acceptable");
}
#[test]
fn not_found_error_class_returns_not_found() {
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.error_class(),
"not_found"
);
}
#[test]
fn unprocessable_error_class_returns_unprocessable() {
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.error_class(),
"unprocessable"
);
}
#[test]
fn unsupported_media_type_error_class_returns_unsupported_media_type() {
assert_eq!(
HttpError::UnsupportedMediaType { received: None }.error_class(),
"unsupported_media_type"
);
}
#[test]
fn body_not_utf8_result_class_returns_client_error() {
assert_eq!(HttpError::BodyNotUtf8.result_class(), "client_error");
}
#[test]
fn empty_body_result_class_returns_client_error() {
assert_eq!(HttpError::EmptyBody.result_class(), "client_error");
}
#[test]
fn internal_result_class_returns_server_error() {
assert_eq!(HttpError::Internal.result_class(), "server_error");
}
#[test]
fn method_not_allowed_result_class_returns_client_error() {
assert_eq!(
HttpError::MethodNotAllowed { allowed: "GET" }.result_class(),
"client_error"
);
}
#[test]
fn not_acceptable_result_class_returns_client_error() {
assert_eq!(HttpError::NotAcceptable.result_class(), "client_error");
}
#[test]
fn not_found_result_class_returns_client_error() {
assert_eq!(
HttpError::NotFound {
method: "GET".to_owned(),
path: "/foo".to_owned()
}
.result_class(),
"client_error"
);
}
#[test]
fn unprocessable_result_class_returns_client_error() {
assert_eq!(
HttpError::Unprocessable {
detail: "bad".to_owned()
}
.result_class(),
"client_error"
);
}
#[test]
fn unsupported_media_type_result_class_returns_client_error() {
assert_eq!(
HttpError::UnsupportedMediaType { received: None }.result_class(),
"client_error"
);
}
}