Skip to main content

minco_http/
response.rs

1use axum::response::{IntoResponse, Response};
2use http::{HeaderName, HeaderValue, header};
3use std::time::{SystemTime, UNIX_EPOCH};
4use thiserror::Error;
5
6pub static DEPRECATION_HEADER: HeaderName = HeaderName::from_static("deprecation");
7pub static SUNSET_HEADER: HeaderName = HeaderName::from_static("sunset");
8
9/// Standard bearer challenges for protected API operations.
10///
11/// Applications that need additional RFC 6750 parameters such as `realm` or
12/// `scope` can use [`ApiResponseMetadata::www_authenticate`] with a validated
13/// [`HeaderValue`].
14#[non_exhaustive]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BearerChallenge {
17    Required,
18    InvalidRequest,
19    InvalidToken,
20    InsufficientScope,
21}
22
23impl BearerChallenge {
24    #[must_use]
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Required => "Bearer",
28            Self::InvalidRequest => r#"Bearer error="invalid_request""#,
29            Self::InvalidToken => r#"Bearer error="invalid_token""#,
30            Self::InsufficientScope => r#"Bearer error="insufficient_scope""#,
31        }
32    }
33}
34
35/// Standard HTTP response metadata used by browser, native, and machine clients.
36///
37/// The wrapper is intentionally transport-only. It does not implement rate
38/// limiting, token validation, or API lifecycle policy for the application.
39#[must_use]
40#[derive(Debug, Clone, Default)]
41pub struct ApiResponseMetadata {
42    retry_after: Option<HeaderValue>,
43    www_authenticate: Option<HeaderValue>,
44    deprecation: Option<HeaderValue>,
45    sunset: Option<HeaderValue>,
46    links: Vec<HeaderValue>,
47}
48
49impl ApiResponseMetadata {
50    pub const fn new() -> Self {
51        Self {
52            retry_after: None,
53            www_authenticate: None,
54            deprecation: None,
55            sunset: None,
56            links: Vec::new(),
57        }
58    }
59
60    /// Adds either a delay-seconds value or an HTTP-date supplied by the application.
61    pub fn retry_after(mut self, value: HeaderValue) -> Self {
62        self.retry_after = Some(value);
63        self
64    }
65
66    pub fn retry_after_seconds(self, seconds: u64) -> Self {
67        self.retry_after(
68            HeaderValue::from_str(&seconds.to_string())
69                .expect("a retry delay generated from u64 is a valid header value"),
70        )
71    }
72
73    pub fn www_authenticate(mut self, value: HeaderValue) -> Self {
74        self.www_authenticate = Some(value);
75        self
76    }
77
78    pub fn bearer_challenge(self, challenge: BearerChallenge) -> Self {
79        self.www_authenticate(HeaderValue::from_static(challenge.as_str()))
80    }
81
82    /// Adds an RFC 9745 Structured Field Date (`@<unix-seconds>`).
83    pub fn deprecation_at(
84        mut self,
85        deprecation: SystemTime,
86    ) -> Result<Self, ApiResponseMetadataError> {
87        let seconds = deprecation
88            .duration_since(UNIX_EPOCH)
89            .map_err(|_| ApiResponseMetadataError::BeforeUnixEpoch)?
90            .as_secs();
91        self.deprecation = Some(
92            HeaderValue::from_str(&format!("@{seconds}"))
93                .expect("a deprecation date generated from u64 is a valid header value"),
94        );
95        Ok(self)
96    }
97
98    /// Adds an RFC 8594 Sunset HTTP-date supplied by the application.
99    pub fn sunset(mut self, sunset: HeaderValue) -> Self {
100        self.sunset = Some(sunset);
101        self
102    }
103
104    /// Adds one Link field value. Repeated calls preserve separate field values.
105    pub fn link(mut self, link: HeaderValue) -> Self {
106        self.links.push(link);
107        self
108    }
109
110    pub fn apply(self, response: &mut Response) {
111        let headers = response.headers_mut();
112        if let Some(value) = self.retry_after {
113            headers.insert(header::RETRY_AFTER, value);
114        }
115        if let Some(value) = self.www_authenticate {
116            headers.insert(header::WWW_AUTHENTICATE, value);
117        }
118        if let Some(value) = self.deprecation {
119            headers.insert(DEPRECATION_HEADER.clone(), value);
120        }
121        if let Some(value) = self.sunset {
122            headers.insert(SUNSET_HEADER.clone(), value);
123        }
124        for link in self.links {
125            headers.append(header::LINK, link);
126        }
127    }
128
129    pub const fn wrap<T>(self, inner: T) -> ApiResponse<T> {
130        ApiResponse {
131            inner,
132            metadata: self,
133        }
134    }
135}
136
137/// An Axum response plus standard cross-client response metadata.
138#[must_use]
139#[derive(Debug, Clone)]
140pub struct ApiResponse<T> {
141    inner: T,
142    metadata: ApiResponseMetadata,
143}
144
145impl<T> IntoResponse for ApiResponse<T>
146where
147    T: IntoResponse,
148{
149    fn into_response(self) -> Response {
150        let mut response = self.inner.into_response();
151        self.metadata.apply(&mut response);
152        response
153    }
154}
155
156#[non_exhaustive]
157#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
158pub enum ApiResponseMetadataError {
159    #[error("deprecation timestamps before the Unix epoch are unsupported")]
160    BeforeUnixEpoch,
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use http::StatusCode;
167    use std::time::Duration;
168
169    #[test]
170    fn metadata_wraps_any_axum_response_without_changing_its_status() {
171        let response = ApiResponseMetadata::new()
172            .retry_after_seconds(30)
173            .bearer_challenge(BearerChallenge::InvalidToken)
174            .deprecation_at(UNIX_EPOCH + Duration::from_hours(500_000))
175            .unwrap()
176            .sunset(HeaderValue::from_static("Sun, 06 Nov 1994 08:49:37 GMT"))
177            .link(HeaderValue::from_static(
178                r#"<https://api.example.invalid/migration>; rel="deprecation""#,
179            ))
180            .link(HeaderValue::from_static(
181                r#"<https://api.example.invalid/replacement>; rel="successor-version""#,
182            ))
183            .wrap((StatusCode::TOO_MANY_REQUESTS, "slow down"))
184            .into_response();
185
186        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
187        assert_eq!(response.headers()[header::RETRY_AFTER], "30");
188        assert_eq!(
189            response.headers()[header::WWW_AUTHENTICATE],
190            r#"Bearer error="invalid_token""#
191        );
192        assert_eq!(response.headers()[&DEPRECATION_HEADER], "@1800000000");
193        assert_eq!(
194            response.headers()[&SUNSET_HEADER],
195            "Sun, 06 Nov 1994 08:49:37 GMT"
196        );
197        assert_eq!(response.headers().get_all(header::LINK).iter().count(), 2);
198    }
199
200    #[test]
201    fn retry_after_accepts_an_http_date() {
202        let response = ApiResponseMetadata::new()
203            .retry_after(HeaderValue::from_static("Sun, 06 Nov 1994 08:49:37 GMT"))
204            .wrap((StatusCode::SERVICE_UNAVAILABLE, "try later"))
205            .into_response();
206
207        assert_eq!(
208            response.headers()[header::RETRY_AFTER],
209            "Sun, 06 Nov 1994 08:49:37 GMT"
210        );
211    }
212
213    #[test]
214    fn deprecation_rejects_dates_before_the_unix_epoch() {
215        let before_epoch = UNIX_EPOCH.checked_sub(Duration::from_secs(1)).unwrap();
216        let error = ApiResponseMetadata::new()
217            .deprecation_at(before_epoch)
218            .unwrap_err();
219        assert_eq!(error, ApiResponseMetadataError::BeforeUnixEpoch);
220    }
221}