use crate::validation::ValidationError;
use http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_uri: String,
pub title: String,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
pub extensions: HashMap<String, serde_json::Value>,
}
impl ProblemDetails {
pub const TYPE_VALIDATION_ERROR: &'static str = "https://spikard.dev/errors/validation-error";
pub const TYPE_NOT_FOUND: &'static str = "https://spikard.dev/errors/not-found";
pub const TYPE_METHOD_NOT_ALLOWED: &'static str = "https://spikard.dev/errors/method-not-allowed";
pub const TYPE_INTERNAL_SERVER_ERROR: &'static str = "https://spikard.dev/errors/internal-server-error";
pub const TYPE_BAD_REQUEST: &'static str = "https://spikard.dev/errors/bad-request";
#[must_use]
pub fn new(type_uri: impl Into<String>, title: impl Into<String>, status: StatusCode) -> Self {
Self {
type_uri: type_uri.into(),
title: title.into(),
status: status.as_u16(),
detail: None,
instance: None,
extensions: HashMap::new(),
}
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
#[must_use]
pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
self.instance = Some(instance.into());
self
}
#[must_use]
pub fn with_extension(mut self, key: impl Into<String>, value: Value) -> Self {
self.extensions.insert(key.into(), value);
self
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn with_extensions(mut self, extensions: Value) -> Self {
if let Some(obj) = extensions.as_object() {
for (key, value) in obj {
self.extensions.insert(key.clone(), value.clone());
}
}
self
}
#[must_use]
pub fn from_validation_error(error: &ValidationError) -> Self {
let error_count = error.errors.len();
let detail = if error_count == 1 {
"1 validation error in request".to_string()
} else {
format!("{error_count} validation errors in request")
};
let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
Self::new(
Self::TYPE_VALIDATION_ERROR,
"Request Validation Failed",
StatusCode::UNPROCESSABLE_ENTITY,
)
.with_detail(detail)
.with_extension("errors", errors_json)
}
pub fn not_found(detail: impl Into<String>) -> Self {
Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
}
pub fn method_not_allowed(detail: impl Into<String>) -> Self {
Self::new(
Self::TYPE_METHOD_NOT_ALLOWED,
"Method Not Allowed",
StatusCode::METHOD_NOT_ALLOWED,
)
.with_detail(detail)
}
pub fn internal_server_error(detail: impl Into<String>) -> Self {
Self::new(
Self::TYPE_INTERNAL_SERVER_ERROR,
"Internal Server Error",
StatusCode::INTERNAL_SERVER_ERROR,
)
.with_detail(detail)
}
pub fn internal_server_error_debug(
detail: impl Into<String>,
exception: impl Into<String>,
traceback: impl Into<String>,
request_data: Value,
) -> Self {
Self::new(
Self::TYPE_INTERNAL_SERVER_ERROR,
"Internal Server Error",
StatusCode::INTERNAL_SERVER_ERROR,
)
.with_detail(detail)
.with_extension("exception", Value::String(exception.into()))
.with_extension("traceback", Value::String(traceback.into()))
.with_extension("request_data", request_data)
}
pub fn bad_request(detail: impl Into<String>) -> Self {
Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
}
#[must_use]
pub fn status_code(&self) -> StatusCode {
StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";
#[cfg(test)]
mod tests {
use super::*;
use crate::validation::{ValidationError, ValidationErrorDetail};
use serde_json::json;
#[test]
fn test_validation_error_conversion() {
let validation_error = ValidationError {
errors: vec![
ValidationErrorDetail {
error_type: "missing".to_string(),
loc: vec!["body".to_string(), "username".to_string()],
msg: "Field required".to_string(),
input: Value::String(String::new()),
ctx: None,
},
ValidationErrorDetail {
error_type: "string_too_short".to_string(),
loc: vec!["body".to_string(), "password".to_string()],
msg: "String should have at least 8 characters".to_string(),
input: Value::String("pass".to_string()),
ctx: Some(json!({"min_length": 8})),
},
],
};
let problem = ProblemDetails::from_validation_error(&validation_error);
assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
assert_eq!(problem.title, "Request Validation Failed");
assert_eq!(problem.status, 422);
assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));
let errors = problem.extensions.get("errors").unwrap();
assert!(errors.is_array());
assert_eq!(errors.as_array().unwrap().len(), 2);
}
#[test]
fn test_problem_details_serialization() {
let problem = ProblemDetails::new(
"https://example.com/probs/out-of-credit",
"You do not have enough credit",
StatusCode::FORBIDDEN,
)
.with_detail("Your current balance is 30, but that costs 50.")
.with_instance("/account/12345/msgs/abc")
.with_extension("balance", json!(30))
.with_extension("accounts", json!(["/account/12345", "/account/67890"]));
let json_str = problem.to_json_pretty().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
assert_eq!(parsed["title"], "You do not have enough credit");
assert_eq!(parsed["status"], 403);
assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
assert_eq!(parsed["balance"], 30);
}
#[test]
fn test_not_found_error() {
let problem = ProblemDetails::not_found("No route matches GET /api/users/999");
assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
assert_eq!(problem.title, "Resource Not Found");
assert_eq!(problem.status, 404);
assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
}
#[test]
fn test_internal_server_error_debug() {
let request_data = json!({
"path_params": {},
"query_params": {},
"body": {"username": "test"}
});
let problem = ProblemDetails::internal_server_error_debug(
"Python handler raised KeyError",
"KeyError: 'username'",
"Traceback (most recent call last):\n ...",
request_data,
);
assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
assert_eq!(problem.status, 500);
assert!(problem.extensions.contains_key("exception"));
assert!(problem.extensions.contains_key("traceback"));
assert!(problem.extensions.contains_key("request_data"));
}
#[test]
fn test_validation_error_conversion_single_error_uses_singular_detail() {
let validation_error = ValidationError {
errors: vec![ValidationErrorDetail {
error_type: "missing".to_string(),
loc: vec!["query".to_string(), "id".to_string()],
msg: "Field required".to_string(),
input: Value::Null,
ctx: None,
}],
};
let problem = ProblemDetails::from_validation_error(&validation_error);
assert_eq!(problem.status, 422);
assert_eq!(problem.detail, Some("1 validation error in request".to_string()));
}
#[test]
fn test_with_extensions_ignores_non_object_values() {
let problem =
ProblemDetails::new("about:blank", "Test", StatusCode::BAD_REQUEST).with_extensions(Value::Bool(true));
assert!(problem.extensions.is_empty());
}
#[test]
fn test_content_type_constant() {
assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
}
}