use axum::response::{IntoResponse, Response};
use http::StatusCode;
use parse_rust_core::{ErrorCode, ErrorDetail, ErrorOrigin, ParseError, PERMISSION_DENIED};
pub const INTERNAL_SERVER_ERROR_MESSAGE: &str = "Internal server error.";
#[derive(Debug, Clone)]
pub struct HttpError {
pub status: StatusCode,
pub message: String,
}
impl HttpError {
pub fn master_key_required(detail: ErrorDetail) -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: match detail {
ErrorDetail::Withheld => PERMISSION_DENIED.to_string(),
ErrorDetail::Disclosed => "unauthorized: master key is required".to_string(),
},
}
}
pub fn unauthorized() -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: "unauthorized".to_string(),
}
}
}
impl IntoResponse for HttpError {
fn into_response(self) -> Response {
let body = format!("{{\"error\":{}}}", json_string(&self.message));
(
self.status,
[(
http::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)],
body,
)
.into_response()
}
}
pub struct ParseErrorResponse(pub ParseError);
impl IntoResponse for ParseErrorResponse {
fn into_response(self) -> Response {
let (status, body) = match self.0.origin {
ErrorOrigin::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
format!(
"{{\"code\":{},\"message\":{}}}",
ErrorCode::InternalServerError.as_i32(),
json_string(INTERNAL_SERVER_ERROR_MESSAGE)
),
),
ErrorOrigin::Parse => {
let status = match self.0.code {
ErrorCode::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::ObjectNotFound => StatusCode::NOT_FOUND,
_ => StatusCode::BAD_REQUEST,
};
(
status,
format!(
"{{\"code\":{},\"error\":{}}}",
self.0.code.as_i32(),
json_string(&self.0.message)
),
)
}
};
(
status,
[(
http::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)],
body,
)
.into_response()
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_two_envelopes_are_distinguishable() {
let http = HttpError::master_key_required(ErrorDetail::Withheld);
assert_eq!(http.message, "Permission denied");
assert_eq!(http.status, StatusCode::FORBIDDEN);
assert_eq!(HttpError::unauthorized().message, "unauthorized");
}
#[test]
fn sanitization_toggles_only_the_master_key_message() {
assert_eq!(
HttpError::master_key_required(ErrorDetail::Disclosed).message,
"unauthorized: master key is required"
);
assert_eq!(HttpError::unauthorized().message, "unauthorized");
}
async fn rendered(e: ParseError) -> (StatusCode, String) {
let response = ParseErrorResponse(e).into_response();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("body");
(status, String::from_utf8(bytes.to_vec()).expect("utf-8"))
}
#[tokio::test]
async fn a_non_parse_error_renders_the_generic_five_hundred() {
let (status, body) = rendered(ParseError::internal(
"pointer permissions: Invoice ownerRef",
))
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, r#"{"code":1,"message":"Internal server error."}"#);
assert!(!body.contains("Invoice"));
assert!(!body.contains("ownerRef"));
assert!(!body.contains("\"error\""));
}
#[tokio::test]
async fn a_parse_error_carrying_code_one_keeps_its_message() {
let (status, body) = rendered(ParseError::new(
ErrorCode::InternalServerError,
"Invalid object ID.",
))
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, r#"{"code":1,"error":"Invalid object ID."}"#);
}
#[tokio::test]
async fn an_ordinary_parse_error_is_unchanged() {
let (status, body) = rendered(ParseError::new(
ErrorCode::ObjectNotFound,
"Object not found.",
))
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body, r#"{"code":101,"error":"Object not found."}"#);
}
#[test]
fn escaping_is_applied_to_messages() {
assert_eq!(json_string(r#"a"b"#), r#""a\"b""#);
assert_eq!(json_string("a\nb"), r#""a\nb""#);
}
}