Skip to main content

rustlavel_http/
versioning.rs

1//! API versions, and telling clients when one is going away.
2//!
3//! Two ways to version, because APIs use both. By path — `/v1/users`,
4//! `/v2/users` — with [`Router::version`](crate::Router::version), which is
5//! the visible, cacheable, curl-friendly form. Or by header — `X-API-Version:
6//! 2`, `Accept: application/vnd.example.v2+json` — with [`VersionHeader`],
7//! for APIs that want one URL per resource forever. Either way the handler
8//! asks `req.api_version()` and gets the same answer.
9//!
10//! The other half is the lifecycle. A version is not retired by deleting it;
11//! it is retired by telling every client, for months, that the day is coming.
12//! [`RouteHandle::deprecated_at`](crate::RouteHandle::deprecated_at) sends
13//! `Deprecation` (RFC 9745) and [`RouteHandle::sunset`](crate::RouteHandle::sunset)
14//! sends `Sunset` (RFC 8594), on every response from the route, so a client
15//! library can log a warning its own developers will see.
16
17use crate::handler::BoxFuture;
18use crate::middleware::{Middleware, Next};
19use crate::request::Request;
20use crate::response::Response;
21use crate::router::Route;
22use crate::status::Status;
23use rustlavel_core::Json;
24
25/// The version a request is for, attached as an extension.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ApiVersion(pub String);
28
29/// Add `Deprecation` and `Sunset` to a response from a route that has them.
30pub(crate) fn stamp_lifecycle(route: &Route, mut response: Response) -> Response {
31    if let Some(at) = route.deprecated_at {
32        // RFC 9745 §2: a structured-field Date, which is `@` and unix seconds.
33        response.headers.set("deprecation", format!("@{at}"));
34    }
35    if let Some(at) = route.sunset {
36        // RFC 8594 §3: an HTTP-date.
37        response.headers.set("sunset", crate::date::http_date(at));
38    }
39    response
40}
41
42/// Read the API version from a header.
43///
44/// ```ignore
45/// App::new()?.middleware(
46///     VersionHeader::new("X-API-Version")
47///         .default("2")
48///         .allow(["1", "2"]),
49/// )
50/// ```
51///
52/// A vendor media type in `Accept` — `application/vnd.example.v2+json` — is
53/// read as well when [`VersionHeader::from_accept`] names the vendor prefix.
54/// A request naming a version that is not allowed is a 400 that lists what
55/// is, rather than a silent fall-through to the default: a client that asked
56/// for v3 and got v1's shape would be worse off than one that got an error.
57#[derive(Debug, Clone)]
58pub struct VersionHeader {
59    header: String,
60    accept_vendor: Option<String>,
61    default: Option<String>,
62    allowed: Option<Vec<String>>,
63}
64
65impl VersionHeader {
66    pub fn new(header: &str) -> Self {
67        VersionHeader {
68            header: header.to_ascii_lowercase(),
69            accept_vendor: None,
70            default: None,
71            allowed: None,
72        }
73    }
74
75    /// The version to assume when the client names none.
76    ///
77    /// Stripe pins this per account; most APIs pin it to the oldest still
78    /// supported, so a client written against it keeps working unchanged.
79    pub fn default(mut self, version: &str) -> Self {
80        self.default = Some(version.to_string());
81        self
82    }
83
84    /// Also accept `Accept: application/vnd.{vendor}.v{N}+json`.
85    pub fn from_accept(mut self, vendor: &str) -> Self {
86        self.accept_vendor = Some(vendor.to_string());
87        self
88    }
89
90    /// The versions that exist. Anything else is a 400.
91    pub fn allow<I, S>(mut self, versions: I) -> Self
92    where
93        I: IntoIterator<Item = S>,
94        S: Into<String>,
95    {
96        self.allowed = Some(versions.into_iter().map(Into::into).collect());
97        self
98    }
99
100    fn requested(&self, request: &Request) -> Option<String> {
101        if let Some(version) = request.header(&self.header) {
102            let version = version.trim();
103            if !version.is_empty() {
104                return Some(version.to_string());
105            }
106        }
107        let vendor = self.accept_vendor.as_deref()?;
108        let accept = request.header("accept")?;
109        // application/vnd.example.v2+json → "2"
110        let marker = format!("application/vnd.{vendor}.v");
111        accept.split(',').find_map(|part| {
112            let rest = part.trim().strip_prefix(marker.as_str())?;
113            let version: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '.').collect();
114            (!version.is_empty()).then_some(version)
115        })
116    }
117}
118
119impl Middleware for VersionHeader {
120    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
121        // A version chosen by the route's path wins over a header; the URL is
122        // the more specific statement of what the client asked for.
123        if request.api_version().is_some() {
124            return next.run(request);
125        }
126
127        let version = self.requested(&request).or_else(|| self.default.clone());
128
129        if let (Some(version), Some(allowed)) = (&version, &self.allowed)
130            && !allowed.contains(version)
131        {
132            let header = self.header.clone();
133            let allowed = allowed.join(", ");
134            let version = version.clone();
135            return Box::pin(async move {
136                Response::new(Status::BAD_REQUEST).with_json(Json::object([
137                    ("message", Json::from(format!("API version `{version}` does not exist."))),
138                    ("header", Json::from(header)),
139                    ("available", Json::from(allowed)),
140                ]))
141            });
142        }
143
144        if let Some(version) = version {
145            request.extend(ApiVersion(version));
146        }
147        next.run(request)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::method::Method;
155    use crate::router::Router;
156    use crate::testing::TestClient;
157
158    fn echo(req: Request) -> impl std::future::Future<Output = Response> {
159        let version = req.api_version().unwrap_or("none").to_string();
160        async move { Response::text(version) }
161    }
162
163    #[tokio::test]
164    async fn path_versions_prefix_routes_and_name_themselves() {
165        let mut router = Router::new();
166        router.version("v1", |v1| {
167            v1.get("/users", echo);
168        });
169        router.version("v2", |v2| {
170            v2.get("/users", echo);
171            v2.group("/admin", |admin| {
172                admin.get("/stats", echo);
173            });
174        });
175        let client = TestClient::new(router);
176
177        assert_eq!(client.get("/v1/users").await.body(), "v1");
178        assert_eq!(client.get("/v2/users").await.body(), "v2");
179        assert_eq!(client.get("/v2/admin/stats").await.body(), "v2", "a group inside keeps the version");
180        client.get("/users").await.assert_not_found();
181    }
182
183    #[tokio::test]
184    async fn a_sunset_route_says_so_on_every_response() {
185        let mut router = Router::new();
186        router.version("v1", |v1| {
187            v1.get("/users", echo).deprecated_at("2026-06-01").sunset("2027-01-01");
188        });
189        router.get("/fresh", echo);
190        let client = TestClient::new(router);
191
192        let old = client.get("/v1/users").await;
193        assert_eq!(old.header("deprecation"), Some("@1780272000"));
194        assert_eq!(old.header("sunset"), Some("Fri, 01 Jan 2027 00:00:00 GMT"));
195
196        let fresh = client.get("/fresh").await;
197        assert_eq!(fresh.header("deprecation"), None);
198        assert_eq!(fresh.header("sunset"), None);
199    }
200
201    #[test]
202    fn sunset_marks_the_route_deprecated_for_the_docs() {
203        let mut router = Router::new();
204        router.get("/old", echo).sunset("2027-01-01");
205        assert!(router.routes()[0].deprecated);
206    }
207
208    #[test]
209    #[should_panic(expected = "wants YYYY-MM-DD")]
210    fn a_typo_in_a_sunset_date_fails_at_startup() {
211        let mut router = Router::new();
212        router.get("/old", echo).sunset("next year");
213    }
214
215    fn header_client(header: VersionHeader) -> TestClient {
216        let mut router = Router::new();
217        router.middleware(header);
218        router.get("/users", echo);
219        router.version("v9", |v9| {
220            v9.get("/users", echo);
221        });
222        TestClient::new(router)
223    }
224
225    #[tokio::test]
226    async fn the_header_names_the_version_and_the_default_fills_in() {
227        let client = header_client(VersionHeader::new("X-API-Version").default("1"));
228        let asked = Request::new(Method::Get, "/users").with_header("x-api-version", "2");
229        assert_eq!(client.send(asked).await.body(), "2");
230        assert_eq!(client.get("/users").await.body(), "1");
231    }
232
233    #[tokio::test]
234    async fn without_a_default_an_unversioned_request_has_no_version() {
235        let client = header_client(VersionHeader::new("X-API-Version"));
236        assert_eq!(client.get("/users").await.body(), "none");
237    }
238
239    #[tokio::test]
240    async fn a_vendor_media_type_in_accept_works_too() {
241        let client = header_client(VersionHeader::new("X-API-Version").from_accept("example"));
242        let request = Request::new(Method::Get, "/users")
243            .with_header("accept", "application/vnd.example.v3+json, application/json;q=0.5");
244        assert_eq!(client.send(request).await.body(), "3");
245    }
246
247    #[tokio::test]
248    async fn an_unknown_version_is_a_400_listing_the_real_ones() {
249        let client = header_client(VersionHeader::new("X-API-Version").default("1").allow(["1", "2"]));
250        let request = Request::new(Method::Get, "/users").with_header("x-api-version", "7");
251        let response = client.send(request).await;
252        let response = response.assert_status(400);
253        let body = response.json();
254        assert!(body.get("message").and_then(Json::as_str).unwrap().contains("`7`"));
255        assert_eq!(body.get("available").and_then(Json::as_str), Some("1, 2"));
256    }
257
258    #[tokio::test]
259    async fn the_path_wins_over_the_header() {
260        let client = header_client(VersionHeader::new("X-API-Version").default("1"));
261        let request = Request::new(Method::Get, "/v9/users").with_header("x-api-version", "2");
262        assert_eq!(client.send(request).await.body(), "v9");
263    }
264}