Skip to main content

api_bones/
deprecated.rs

1//! Deprecation marker type (RFC 8594).
2//!
3//! [`Deprecated`] carries a sunset date and an optional replacement link,
4//! and can inject the standard `Deprecation` and `Sunset` response headers
5//! defined in [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594).
6//!
7//! # Example
8//!
9//! ```rust
10//! use api_bones::deprecated::Deprecated;
11//!
12//! let d = Deprecated::new("2025-12-31")
13//!     .with_link("https://api.example.com/v2/docs");
14//! assert_eq!(d.sunset, "2025-12-31");
15//! assert!(d.link.is_some());
16//! ```
17
18#[cfg(all(not(feature = "std"), feature = "alloc"))]
19use alloc::string::String;
20use core::fmt;
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Serialize};
23
24// ---------------------------------------------------------------------------
25// Deprecated
26// ---------------------------------------------------------------------------
27
28/// Deprecation metadata for an API resource or endpoint.
29///
30/// Carries the `Sunset` date (RFC 8594) and an optional `Link` to replacement
31/// documentation. Use [`inject_headers`](Deprecated::inject_headers) to attach
32/// the standard headers to an HTTP response.
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
35#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
36#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
37pub struct Deprecated {
38    /// RFC 7231 HTTP-date (or RFC 3339 date) after which the resource is gone.
39    ///
40    /// Example: `"2025-12-31"` or `"Sat, 31 Dec 2025 00:00:00 GMT"`.
41    pub sunset: String,
42
43    /// URL of the replacement resource or migration guide (`rel="successor-version"`).
44    #[cfg_attr(
45        feature = "serde",
46        serde(default, skip_serializing_if = "Option::is_none")
47    )]
48    pub link: Option<String>,
49}
50
51impl Deprecated {
52    /// Create a new deprecation marker with the given sunset date.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use api_bones::deprecated::Deprecated;
58    ///
59    /// let d = Deprecated::new("2025-12-31");
60    /// assert_eq!(d.sunset, "2025-12-31");
61    /// assert!(d.link.is_none());
62    /// ```
63    #[must_use]
64    pub fn new(sunset: impl Into<String>) -> Self {
65        Self {
66            sunset: sunset.into(),
67            link: None,
68        }
69    }
70
71    /// Attach a replacement link.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use api_bones::deprecated::Deprecated;
77    ///
78    /// let d = Deprecated::new("2025-12-31")
79    ///     .with_link("https://api.example.com/v2");
80    /// assert_eq!(d.link.as_deref(), Some("https://api.example.com/v2"));
81    /// ```
82    #[must_use]
83    pub fn with_link(mut self, link: impl Into<String>) -> Self {
84        self.link = Some(link.into());
85        self
86    }
87
88    /// Build the value for the `Deprecation` header.
89    ///
90    /// Per RFC 8594 the value is `true` for a permanently-deprecated resource.
91    #[must_use]
92    pub fn deprecation_header_value(&self) -> &'static str {
93        "true"
94    }
95
96    /// Build the value for the `Sunset` header (the sunset date as-is).
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use api_bones::deprecated::Deprecated;
102    ///
103    /// let d = Deprecated::new("Sat, 31 Dec 2025 00:00:00 GMT");
104    /// assert_eq!(d.sunset_header_value(), "Sat, 31 Dec 2025 00:00:00 GMT");
105    /// ```
106    #[must_use]
107    pub fn sunset_header_value(&self) -> &str {
108        &self.sunset
109    }
110
111    /// Build the `Link` header value for the replacement URL if present.
112    ///
113    /// Produces `<url>; rel="successor-version"` per RFC 8288.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use api_bones::deprecated::Deprecated;
119    ///
120    /// let d = Deprecated::new("2025-12-31")
121    ///     .with_link("https://api.example.com/v2");
122    /// assert_eq!(
123    ///     d.link_header_value().as_deref(),
124    ///     Some("<https://api.example.com/v2>; rel=\"successor-version\"")
125    /// );
126    /// ```
127    #[must_use]
128    #[cfg(any(feature = "std", feature = "alloc"))]
129    pub fn link_header_value(&self) -> Option<String> {
130        #[cfg(all(not(feature = "std"), feature = "alloc"))]
131        use alloc::format;
132        self.link
133            .as_deref()
134            .map(|url| format!("<{url}>; rel=\"successor-version\""))
135    }
136
137    /// Inject `Deprecation`, `Sunset`, and (optionally) `Link` headers into an
138    /// [`http::HeaderMap`].
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if any header value contains characters invalid for HTTP headers.
143    #[cfg(feature = "http")]
144    pub fn inject_headers(
145        &self,
146        headers: &mut http::HeaderMap,
147    ) -> Result<(), http::header::InvalidHeaderValue> {
148        use http::header::{HeaderName, HeaderValue};
149
150        headers.insert(
151            HeaderName::from_static("deprecation"),
152            HeaderValue::from_static("true"),
153        );
154        headers.insert(
155            HeaderName::from_static("sunset"),
156            HeaderValue::from_str(&self.sunset)?,
157        );
158        if let Some(link_val) = self.link_header_value() {
159            headers.insert(http::header::LINK, HeaderValue::from_str(&link_val)?);
160        }
161        Ok(())
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Display
167// ---------------------------------------------------------------------------
168
169impl fmt::Display for Deprecated {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        write!(f, "Deprecated(sunset={})", self.sunset)
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Tests
177// ---------------------------------------------------------------------------
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn new_sets_sunset() {
185        let d = Deprecated::new("2025-12-31");
186        assert_eq!(d.sunset, "2025-12-31");
187        assert!(d.link.is_none());
188    }
189
190    #[test]
191    fn with_link() {
192        let d = Deprecated::new("2025-12-31").with_link("https://example.com/v2");
193        assert_eq!(d.link.as_deref(), Some("https://example.com/v2"));
194    }
195
196    #[test]
197    fn header_values() {
198        let d = Deprecated::new("2025-12-31");
199        assert_eq!(d.deprecation_header_value(), "true");
200        assert_eq!(d.sunset_header_value(), "2025-12-31");
201    }
202
203    #[cfg(any(feature = "std", feature = "alloc"))]
204    #[test]
205    fn link_header_value_format() {
206        let d = Deprecated::new("2025-12-31").with_link("https://example.com/v2");
207        assert_eq!(
208            d.link_header_value().as_deref(),
209            Some("<https://example.com/v2>; rel=\"successor-version\"")
210        );
211    }
212
213    #[cfg(any(feature = "std", feature = "alloc"))]
214    #[test]
215    fn link_header_value_none() {
216        let d = Deprecated::new("2025-12-31");
217        assert!(d.link_header_value().is_none());
218    }
219
220    #[cfg(feature = "serde")]
221    #[test]
222    fn serde_round_trip() {
223        let d = Deprecated::new("2025-12-31").with_link("https://example.com/v2");
224        let json = serde_json::to_value(&d).unwrap();
225        assert_eq!(json["sunset"], "2025-12-31");
226        assert_eq!(json["link"], "https://example.com/v2");
227        let back: Deprecated = serde_json::from_value(json).unwrap();
228        assert_eq!(back, d);
229    }
230
231    #[cfg(feature = "serde")]
232    #[test]
233    fn serde_omits_null_link() {
234        let d = Deprecated::new("2025-12-31");
235        let json = serde_json::to_value(&d).unwrap();
236        assert!(json.get("link").is_none());
237    }
238
239    #[test]
240    fn display_format() {
241        let d = Deprecated::new("2025-12-31");
242        assert_eq!(d.to_string(), "Deprecated(sunset=2025-12-31)");
243    }
244
245    #[cfg(feature = "http")]
246    #[test]
247    fn inject_headers_sets_deprecation_and_sunset() {
248        let d = Deprecated::new("Sat, 31 Dec 2025 00:00:00 GMT");
249        let mut headers = http::HeaderMap::new();
250        d.inject_headers(&mut headers).unwrap();
251        assert_eq!(headers["deprecation"], "true");
252        assert_eq!(headers["sunset"], "Sat, 31 Dec 2025 00:00:00 GMT");
253        assert!(headers.get(http::header::LINK).is_none());
254    }
255
256    #[cfg(feature = "http")]
257    #[test]
258    fn inject_headers_with_link() {
259        let d = Deprecated::new("2025-12-31").with_link("https://example.com/v2");
260        let mut headers = http::HeaderMap::new();
261        d.inject_headers(&mut headers).unwrap();
262        assert_eq!(headers["deprecation"], "true");
263        let link = headers[http::header::LINK].to_str().unwrap();
264        assert!(link.contains("https://example.com/v2"));
265        assert!(link.contains("successor-version"));
266    }
267}