Skip to main content

durable_streams_server/protocol/
problem.rs

1use axum::{
2    body::Body,
3    extract::OriginalUri,
4    http::{
5        HeaderMap, HeaderValue, StatusCode,
6        header::{CONTENT_TYPE, IntoHeaderName},
7    },
8    response::{IntoResponse, Response},
9};
10use serde::{Deserialize, Serialize};
11
12/// RFC 9457 problem details payload used for server error responses.
13///
14/// The field names intentionally match the wire format so the same type can be
15/// reused by future client-side parsing without another translation layer.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct ProblemDetails {
18    /// URI identifying the problem type.
19    #[serde(rename = "type")]
20    pub problem_type: String,
21    /// Short, human-readable summary of the problem type.
22    pub title: String,
23    /// HTTP status code. Must match the response status line.
24    pub status: u16,
25    /// Machine-readable error code.
26    pub code: String,
27    /// Per-instance explanation of the error.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub detail: Option<String>,
30    /// Request path or URI for the failing resource.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub instance: Option<String>,
33}
34
35impl ProblemDetails {
36    /// Create a new problem details payload with the required RFC 9457 fields.
37    #[must_use]
38    pub fn new(
39        problem_type: &'static str,
40        title: &'static str,
41        status: StatusCode,
42        code: &'static str,
43    ) -> Self {
44        Self {
45            problem_type: problem_type.to_string(),
46            title: title.to_string(),
47            status: status.as_u16(),
48            code: code.to_string(),
49            detail: None,
50            instance: None,
51        }
52    }
53
54    /// Attach a problem-specific detail string.
55    #[must_use]
56    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
57        self.detail = Some(detail.into());
58        self
59    }
60
61    /// Attach the request instance path/URI.
62    #[must_use]
63    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
64        self.instance = Some(instance.into());
65        self
66    }
67}
68
69/// Telemetry metadata copied from the final problem response.
70#[derive(Debug, Clone)]
71pub struct ProblemTelemetry {
72    pub problem_type: String,
73    pub code: String,
74    pub title: String,
75    pub detail: Option<String>,
76    pub error_class: Option<String>,
77    pub storage_backend: Option<String>,
78    pub storage_operation: Option<String>,
79    pub internal_detail: Option<String>,
80    pub retry_after_secs: Option<u32>,
81}
82
83impl From<&ProblemDetails> for ProblemTelemetry {
84    fn from(problem: &ProblemDetails) -> Self {
85        Self {
86            problem_type: problem.problem_type.clone(),
87            code: problem.code.clone(),
88            title: problem.title.clone(),
89            detail: problem.detail.clone(),
90            error_class: None,
91            storage_backend: None,
92            storage_operation: None,
93            internal_detail: None,
94            retry_after_secs: None,
95        }
96    }
97}
98
99/// Builder for structured error responses with protocol-specific headers.
100#[derive(Debug, Clone)]
101pub struct ProblemResponse {
102    problem: Box<ProblemDetails>,
103    headers: HeaderMap,
104    telemetry: Option<Box<ProblemTelemetry>>,
105}
106
107/// Response result alias for handlers that emit structured problem details.
108pub type Result<T> = std::result::Result<T, ProblemResponse>;
109
110impl ProblemResponse {
111    /// Create a problem response from an RFC 9457 payload.
112    #[must_use]
113    pub fn new(problem: ProblemDetails) -> Self {
114        Self {
115            problem: Box::new(problem),
116            headers: HeaderMap::new(),
117            telemetry: None,
118        }
119    }
120
121    /// Attach a problem detail.
122    #[must_use]
123    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
124        self.problem.detail = Some(detail.into());
125        self
126    }
127
128    /// Attach the request instance path/URI.
129    #[must_use]
130    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
131        self.problem.instance = Some(instance.into());
132        self
133    }
134
135    /// Attach an additional response header.
136    #[must_use]
137    pub fn with_header<K>(mut self, key: K, value: HeaderValue) -> Self
138    where
139        K: IntoHeaderName,
140    {
141        self.headers.insert(key, value);
142        self
143    }
144
145    /// Attach explicit telemetry metadata that differs from the public problem payload.
146    #[must_use]
147    pub fn with_telemetry(mut self, telemetry: ProblemTelemetry) -> Self {
148        self.telemetry = Some(Box::new(telemetry));
149        self
150    }
151}
152
153/// Convert the actual request target into a relative `instance` reference.
154///
155/// This intentionally excludes scheme/authority information so error payloads
156/// do not expose proxy or load-balancer host configuration to clients.
157#[must_use]
158pub fn request_instance(OriginalUri(uri): &OriginalUri) -> String {
159    uri.path_and_query().map_or_else(
160        || uri.path().to_string(),
161        |value| value.as_str().to_string(),
162    )
163}
164
165impl IntoResponse for ProblemResponse {
166    fn into_response(self) -> Response {
167        let status =
168            StatusCode::from_u16(self.problem.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
169        let telemetry = self
170            .telemetry
171            .map_or_else(|| ProblemTelemetry::from(&*self.problem), |t| *t);
172        let body = match serde_json::to_vec(&self.problem) {
173            Ok(body) => body,
174            Err(_) => br#"{"type":"/errors/internal","title":"Internal Server Error","status":500,"code":"INTERNAL_ERROR"}"#.to_vec(),
175        };
176
177        let mut response = Response::new(Body::from(body));
178        *response.status_mut() = status;
179
180        let headers = response.headers_mut();
181        headers.insert(
182            CONTENT_TYPE,
183            HeaderValue::from_static("application/problem+json"),
184        );
185        headers.extend(self.headers);
186
187        response.extensions_mut().insert(telemetry);
188        response
189    }
190}