Skip to main content

aa_auth/
error.rs

1//! RFC 7807 Problem Details error responses.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6
7/// RFC 7807 Problem Details JSON body.
8#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
9#[schema(example = json!({
10    "type": "about:blank",
11    "title": "Not Found",
12    "status": 404,
13    "detail": "No route matched: /unknown",
14    "instance": "/unknown"
15}))]
16pub struct ProblemDetail {
17    /// URI reference identifying the problem type.
18    #[serde(rename = "type")]
19    pub type_uri: String,
20    /// Short human-readable summary.
21    pub title: String,
22    /// HTTP status code.
23    pub status: u16,
24    /// Human-readable explanation specific to this occurrence.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub detail: Option<String>,
27    /// URI reference identifying the specific occurrence.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub instance: Option<String>,
30    /// Stable machine-readable error code (e.g. `"invalid_threshold"`)
31    /// for clients that need to branch on the specific failure
32    /// without parsing the human-readable `detail`. Omitted from the
33    /// wire when unset so existing endpoints stay byte-identical.
34    ///
35    /// Codes are static identifiers — `&'static str` keeps the struct
36    /// small enough that handlers returning `Result<_, ProblemDetail>`
37    /// stay under clippy's `result_large_err` threshold.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub error_code: Option<&'static str>,
40}
41
42impl ProblemDetail {
43    /// Create a `ProblemDetail` from an HTTP status code.
44    pub fn from_status(status: StatusCode) -> Self {
45        Self {
46            type_uri: "about:blank".to_string(),
47            title: status.canonical_reason().unwrap_or("Unknown Error").to_string(),
48            status: status.as_u16(),
49            detail: None,
50            instance: None,
51            error_code: None,
52        }
53    }
54
55    /// Attach a human-readable detail message.
56    #[must_use]
57    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
58        self.detail = Some(detail.into());
59        self
60    }
61
62    /// Attach the request URI as the instance identifier.
63    #[must_use]
64    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
65        self.instance = Some(instance.into());
66        self
67    }
68
69    /// Attach a stable machine-readable error code.
70    #[must_use]
71    pub fn with_error_code(mut self, code: &'static str) -> Self {
72        self.error_code = Some(code);
73        self
74    }
75}
76
77impl IntoResponse for ProblemDetail {
78    fn into_response(self) -> Response {
79        let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
80        let body = serde_json::to_string(&self)
81            .unwrap_or_else(|_| r#"{"type":"about:blank","title":"Internal Server Error","status":500}"#.to_string());
82
83        (
84            status,
85            [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
86            body,
87        )
88            .into_response()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn error_code_is_omitted_when_unset() {
98        let problem = ProblemDetail::from_status(StatusCode::NOT_FOUND).with_detail("missing");
99        let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
100        assert!(
101            v.get("error_code").is_none(),
102            "unset error_code must not appear on the wire"
103        );
104    }
105
106    #[test]
107    fn error_code_is_serialized_when_set() {
108        let problem = ProblemDetail::from_status(StatusCode::CONFLICT)
109            .with_detail("duplicate")
110            .with_error_code("rule_name_conflict");
111        let v: serde_json::Value = serde_json::to_value(&problem).unwrap();
112        assert_eq!(v["error_code"], "rule_name_conflict");
113        assert_eq!(v["status"], 409);
114    }
115}