fastapi_core/
versioning.rs1use std::fmt;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub struct ApiVersion(pub u32);
30
31impl ApiVersion {
32 pub fn from_path(path: &str) -> Option<Self> {
36 let path = path.strip_prefix('/')?;
37 let seg = path.split('/').next()?;
38 let ver_str = seg.strip_prefix('v').or_else(|| seg.strip_prefix('V'))?;
39 ver_str.parse::<u32>().ok().map(ApiVersion)
40 }
41
42 pub fn from_header_value(value: &str) -> Option<Self> {
44 let trimmed = value.trim();
45 let num_str = trimmed
46 .strip_prefix('v')
47 .or_else(|| trimmed.strip_prefix('V'))
48 .unwrap_or(trimmed);
49 num_str.parse::<u32>().ok().map(ApiVersion)
50 }
51
52 pub fn from_accept_header(accept: &str) -> Option<Self> {
56 for part in accept.split(';') {
58 let part = part.trim();
59 for segment in part.split('.') {
61 if let Some(num_str) = segment
62 .strip_prefix('v')
63 .or_else(|| segment.strip_prefix('V'))
64 {
65 let digits: String = num_str.chars().take_while(char::is_ascii_digit).collect();
67 if let Ok(n) = digits.parse::<u32>() {
68 return Some(ApiVersion(n));
69 }
70 }
71 }
72 }
73 None
74 }
75
76 pub fn strip_prefix(path: &str) -> &str {
81 let Some(rest) = path.strip_prefix('/') else {
82 return path;
83 };
84 let Some(after_seg) = rest.find('/') else {
85 return path;
86 };
87 let seg = &rest[..after_seg];
88 if seg.starts_with('v') || seg.starts_with('V') {
89 let num_part = &seg[1..];
90 if num_part.chars().all(|c| c.is_ascii_digit()) && !num_part.is_empty() {
91 return &rest[after_seg..];
92 }
93 }
94 path
95 }
96}
97
98impl fmt::Display for ApiVersion {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 write!(f, "v{}", self.0)
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum VersionStrategy {
107 UrlPrefix,
109 Header,
111 AcceptHeader,
113}
114
115#[derive(Debug, Clone)]
117pub struct VersionConfig {
118 pub strategy: VersionStrategy,
120 pub current_version: u32,
122 pub supported_versions: Vec<u32>,
124 pub deprecated_versions: Vec<u32>,
126 pub version_header: String,
128 pub deprecation_header: String,
130}
131
132impl Default for VersionConfig {
133 fn default() -> Self {
134 Self {
135 strategy: VersionStrategy::UrlPrefix,
136 current_version: 1,
137 supported_versions: vec![1],
138 deprecated_versions: Vec::new(),
139 version_header: "X-API-Version".to_string(),
140 deprecation_header: "Deprecation".to_string(),
141 }
142 }
143}
144
145impl VersionConfig {
146 #[must_use]
148 pub fn new() -> Self {
149 Self::default()
150 }
151
152 #[must_use]
154 pub fn strategy(mut self, strategy: VersionStrategy) -> Self {
155 self.strategy = strategy;
156 self
157 }
158
159 #[must_use]
161 pub fn current(mut self, version: u32) -> Self {
162 self.current_version = version;
163 self
164 }
165
166 #[must_use]
168 pub fn supported(mut self, versions: &[u32]) -> Self {
169 self.supported_versions = versions.to_vec();
170 self
171 }
172
173 #[must_use]
175 pub fn deprecated(mut self, versions: &[u32]) -> Self {
176 self.deprecated_versions = versions.to_vec();
177 self
178 }
179
180 #[must_use]
182 pub fn version_header(mut self, name: impl Into<String>) -> Self {
183 self.version_header = name.into();
184 self
185 }
186
187 pub fn is_supported(&self, version: &ApiVersion) -> bool {
189 self.supported_versions.contains(&version.0)
190 }
191
192 pub fn is_deprecated(&self, version: &ApiVersion) -> bool {
194 self.deprecated_versions.contains(&version.0)
195 }
196
197 pub fn extract_from_path(&self, path: &str) -> Option<ApiVersion> {
199 ApiVersion::from_path(path)
200 }
201
202 pub fn extract_from_header(&self, value: &str) -> Option<ApiVersion> {
204 ApiVersion::from_header_value(value)
205 }
206
207 pub fn deprecation_warning(&self, version: &ApiVersion) -> Option<String> {
209 if self.is_deprecated(version) {
210 Some(format!(
211 "API version {} is deprecated. Please migrate to v{}.",
212 version, self.current_version
213 ))
214 } else {
215 None
216 }
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn api_version_from_path() {
226 assert_eq!(ApiVersion::from_path("/v1/users"), Some(ApiVersion(1)));
227 assert_eq!(ApiVersion::from_path("/v2/items/5"), Some(ApiVersion(2)));
228 assert_eq!(ApiVersion::from_path("/v10/"), Some(ApiVersion(10)));
229 assert_eq!(ApiVersion::from_path("/users"), None);
230 assert_eq!(ApiVersion::from_path("/"), None);
231 assert_eq!(ApiVersion::from_path(""), None);
232 }
233
234 #[test]
235 fn api_version_from_header() {
236 assert_eq!(ApiVersion::from_header_value("1"), Some(ApiVersion(1)));
237 assert_eq!(ApiVersion::from_header_value("v2"), Some(ApiVersion(2)));
238 assert_eq!(ApiVersion::from_header_value(" 3 "), Some(ApiVersion(3)));
239 assert_eq!(ApiVersion::from_header_value("abc"), None);
240 }
241
242 #[test]
243 fn api_version_from_accept() {
244 assert_eq!(
245 ApiVersion::from_accept_header("application/vnd.myapi.v1+json"),
246 Some(ApiVersion(1))
247 );
248 assert_eq!(
249 ApiVersion::from_accept_header("application/vnd.api.v3+json; charset=utf-8"),
250 Some(ApiVersion(3))
251 );
252 assert_eq!(ApiVersion::from_accept_header("application/json"), None);
253 }
254
255 #[test]
256 fn strip_version_prefix() {
257 assert_eq!(ApiVersion::strip_prefix("/v1/users"), "/users");
258 assert_eq!(ApiVersion::strip_prefix("/v2/items/5"), "/items/5");
259 assert_eq!(ApiVersion::strip_prefix("/users"), "/users");
260 assert_eq!(ApiVersion::strip_prefix("/"), "/");
261 }
262
263 #[test]
264 fn version_display() {
265 assert_eq!(ApiVersion(1).to_string(), "v1");
266 assert_eq!(ApiVersion(42).to_string(), "v42");
267 }
268
269 #[test]
270 fn version_config_builder() {
271 let config = VersionConfig::new()
272 .strategy(VersionStrategy::Header)
273 .current(3)
274 .supported(&[1, 2, 3])
275 .deprecated(&[1]);
276
277 assert_eq!(config.strategy, VersionStrategy::Header);
278 assert_eq!(config.current_version, 3);
279 assert!(config.is_supported(&ApiVersion(2)));
280 assert!(!config.is_supported(&ApiVersion(4)));
281 assert!(config.is_deprecated(&ApiVersion(1)));
282 assert!(!config.is_deprecated(&ApiVersion(2)));
283 }
284
285 #[test]
286 fn deprecation_warning() {
287 let config = VersionConfig::new()
288 .current(2)
289 .supported(&[1, 2])
290 .deprecated(&[1]);
291
292 let warning = config.deprecation_warning(&ApiVersion(1));
293 assert!(warning.is_some());
294 assert!(warning.unwrap().contains("v2"));
295
296 assert!(config.deprecation_warning(&ApiVersion(2)).is_none());
297 }
298
299 #[test]
300 fn version_config_defaults() {
301 let config = VersionConfig::default();
302 assert_eq!(config.strategy, VersionStrategy::UrlPrefix);
303 assert_eq!(config.current_version, 1);
304 assert_eq!(config.version_header, "X-API-Version");
305 }
306
307 #[test]
308 fn version_ordering() {
309 assert!(ApiVersion(1) < ApiVersion(2));
310 assert!(ApiVersion(3) > ApiVersion(1));
311 assert_eq!(ApiVersion(1), ApiVersion(1));
312 }
313}