rustlavel_http/
versioning.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ApiVersion(pub String);
28
29pub(crate) fn stamp_lifecycle(route: &Route, mut response: Response) -> Response {
31 if let Some(at) = route.deprecated_at {
32 response.headers.set("deprecation", format!("@{at}"));
34 }
35 if let Some(at) = route.sunset {
36 response.headers.set("sunset", crate::date::http_date(at));
38 }
39 response
40}
41
42#[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 pub fn default(mut self, version: &str) -> Self {
80 self.default = Some(version.to_string());
81 self
82 }
83
84 pub fn from_accept(mut self, vendor: &str) -> Self {
86 self.accept_vendor = Some(vendor.to_string());
87 self
88 }
89
90 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 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 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}