use axum::{
http::{HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct InvalidParam {
pub name: String,
pub reason: String,
}
impl InvalidParam {
pub(crate) fn new(name: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
name: name.into(),
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
struct ProblemInner {
#[serde(rename = "type")]
type_uri: String,
title: String,
status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
instance: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_code: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
invalid_params: Vec<InvalidParam>,
#[serde(skip)]
allow: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub(crate) struct Problem(Box<ProblemInner>);
impl Problem {
fn new(status: StatusCode, error_code: &str) -> Self {
Self(Box::new(ProblemInner {
type_uri: format!("/problems/{}", error_code.replace('_', "-")),
title: status.canonical_reason().unwrap_or("Error").to_string(),
status: status.as_u16(),
detail: None,
instance: None,
request_id: None,
error_code: Some(error_code.to_string()),
invalid_params: Vec::new(),
allow: None,
}))
}
pub(crate) fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.0.detail = Some(detail.into());
self
}
pub(crate) fn with_invalid_params(mut self, params: Vec<InvalidParam>) -> Self {
self.0.invalid_params = params;
self
}
pub(crate) fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.0.request_id = Some(request_id.into());
self
}
#[cfg(test)]
pub(crate) fn status(&self) -> u16 {
self.0.status
}
#[cfg(test)]
pub(crate) fn error_code(&self) -> Option<&str> {
self.0.error_code.as_deref()
}
#[cfg(test)]
pub(crate) fn detail(&self) -> Option<&str> {
self.0.detail.as_deref()
}
#[cfg(test)]
pub(crate) fn invalid_params(&self) -> &[InvalidParam] {
&self.0.invalid_params
}
pub(crate) fn invalid_query(detail: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "invalid_query").with_detail(detail)
}
pub(crate) fn invalid_coordinate(params: Vec<InvalidParam>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "invalid_coordinate")
.with_detail("coordinate out of range")
.with_invalid_params(params)
}
pub(crate) fn limit_too_large(params: Vec<InvalidParam>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "limit_too_large")
.with_detail("requested result count exceeds the maximum")
.with_invalid_params(params)
}
pub(crate) fn malformed_parameter(detail: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "malformed_parameter").with_detail(detail)
}
pub(crate) fn not_found() -> Self {
Self::new(StatusCode::NOT_FOUND, "not_found").with_detail("unknown route")
}
pub(crate) fn method_not_allowed(allow: impl Into<String>) -> Self {
let allow = allow.into();
let mut problem = Self::new(StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed")
.with_detail("method not allowed for this route");
problem.0.allow = Some(allow);
problem
}
pub(crate) fn internal() -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
}
impl IntoResponse for Problem {
fn into_response(self) -> Response {
let status =
StatusCode::from_u16(self.0.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let allow = self.0.allow.clone();
let body = serde_json::to_vec(&self).unwrap_or_else(|_| b"{}".to_vec());
let mut response = (status, body).into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/problem+json"),
);
if let Some(allow) = allow
&& let Ok(value) = HeaderValue::from_str(&allow)
{
response.headers_mut().insert(header::ALLOW, value);
}
response
}
}
pub(crate) fn classify_search_error(error: anyhow::Error) -> Problem {
let details = format!("{error:#}");
if details.contains("failed to parse search query") {
Problem::invalid_query("invalid search query")
} else {
Problem::internal()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
async fn body_json(problem: Problem) -> (StatusCode, serde_json::Value) {
let response = problem.into_response();
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
assert_eq!(content_type, "application/problem+json");
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("read problem body");
let value: serde_json::Value = serde_json::from_slice(&bytes).expect("valid problem json");
(status, value)
}
#[tokio::test]
async fn renders_problem_json_with_required_members() {
let (status, value) =
body_json(Problem::invalid_query("invalid search query").with_request_id("req-123"))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(value["type"], "/problems/invalid-query");
assert_eq!(value["title"], "Bad Request");
assert_eq!(value["status"], 400);
assert_eq!(value["detail"], "invalid search query");
assert_eq!(value["error_code"], "invalid_query");
assert_eq!(value["request_id"], "req-123");
}
#[tokio::test]
async fn renders_invalid_params() {
let (status, value) = body_json(Problem::invalid_coordinate(vec![InvalidParam::new(
"lat",
"must be within [-90, 90]",
)]))
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(value["invalid_params"][0]["name"], "lat");
assert_eq!(
value["invalid_params"][0]["reason"],
"must be within [-90, 90]"
);
}
#[tokio::test]
async fn internal_error_leaks_no_detail() {
let (status, value) = body_json(Problem::internal()).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(value.get("detail").is_none());
assert_eq!(value["error_code"], "internal_error");
}
#[test]
fn classify_maps_parse_error_to_400() {
let problem = classify_search_error(anyhow::anyhow!("failed to parse search query \"(\""));
assert_eq!(problem.status(), 400);
assert_eq!(problem.error_code(), Some("invalid_query"));
}
#[test]
fn classify_maps_other_errors_to_500_without_detail() {
let problem = classify_search_error(anyhow::anyhow!("tantivy index read failed"));
assert_eq!(problem.status(), 500);
assert!(problem.detail().is_none());
}
}