Skip to main content

fastapi_core/
versioning.rs

1//! API versioning patterns.
2//!
3//! Supports three strategies for API version negotiation:
4//!
5//! - **URL prefix**: `/v1/users`, `/v2/users`
6//! - **Header**: `X-API-Version: 1`
7//! - **Accept header**: `application/vnd.myapi.v1+json`
8//!
9//! # Example
10//!
11//! ```
12//! use fastapi_core::versioning::{ApiVersion, VersionStrategy, VersionConfig};
13//!
14//! let config = VersionConfig::new()
15//!     .strategy(VersionStrategy::UrlPrefix)
16//!     .current(2)
17//!     .supported(&[1, 2])
18//!     .deprecated(&[1]);
19//!
20//! let v = ApiVersion::from_path("/v1/users");
21//! assert_eq!(v, Some(ApiVersion(1)));
22//! assert!(config.is_deprecated(&ApiVersion(1)));
23//! ```
24
25use std::fmt;
26
27/// An API version number.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub struct ApiVersion(pub u32);
30
31impl ApiVersion {
32    /// Extract version from a URL path prefix like `/v1/...`.
33    ///
34    /// Returns `None` if the path doesn't start with `/v<N>`.
35    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    /// Extract version from a header value like `"2"` or `"v2"`.
43    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    /// Extract version from an Accept header like `application/vnd.myapi.v2+json`.
53    ///
54    /// Looks for a `v<N>` pattern within the media type.
55    pub fn from_accept_header(accept: &str) -> Option<Self> {
56        // Look for vnd.*.v<N> pattern
57        for part in accept.split(';') {
58            let part = part.trim();
59            // Find v<N> in the media type
60            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                    // Take only digits before any '+' suffix
66                    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    /// Strip the version prefix from a path.
77    ///
78    /// `/v1/users` → `/users`, `/v2/items/5` → `/items/5`.
79    /// Returns the original path if no version prefix is found.
80    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/// Strategy for extracting the API version from a request.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum VersionStrategy {
107    /// Version in URL path prefix: `/v1/users`.
108    UrlPrefix,
109    /// Version in a custom header (default: `X-API-Version`).
110    Header,
111    /// Version in Accept header: `application/vnd.myapi.v1+json`.
112    AcceptHeader,
113}
114
115/// Configuration for API versioning.
116#[derive(Debug, Clone)]
117pub struct VersionConfig {
118    /// How to extract the version.
119    pub strategy: VersionStrategy,
120    /// The current (latest) version.
121    pub current_version: u32,
122    /// All supported versions.
123    pub supported_versions: Vec<u32>,
124    /// Deprecated versions (still work, but emit a warning header).
125    pub deprecated_versions: Vec<u32>,
126    /// Header name for the Header strategy.
127    pub version_header: String,
128    /// Header added to responses for deprecated versions.
129    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    /// Create a new version config with defaults.
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Set the versioning strategy.
153    #[must_use]
154    pub fn strategy(mut self, strategy: VersionStrategy) -> Self {
155        self.strategy = strategy;
156        self
157    }
158
159    /// Set the current version.
160    #[must_use]
161    pub fn current(mut self, version: u32) -> Self {
162        self.current_version = version;
163        self
164    }
165
166    /// Set supported versions.
167    #[must_use]
168    pub fn supported(mut self, versions: &[u32]) -> Self {
169        self.supported_versions = versions.to_vec();
170        self
171    }
172
173    /// Set deprecated versions.
174    #[must_use]
175    pub fn deprecated(mut self, versions: &[u32]) -> Self {
176        self.deprecated_versions = versions.to_vec();
177        self
178    }
179
180    /// Set the version header name.
181    #[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    /// Check if a version is supported.
188    pub fn is_supported(&self, version: &ApiVersion) -> bool {
189        self.supported_versions.contains(&version.0)
190    }
191
192    /// Check if a version is deprecated.
193    pub fn is_deprecated(&self, version: &ApiVersion) -> bool {
194        self.deprecated_versions.contains(&version.0)
195    }
196
197    /// Extract version from a request path (URL prefix strategy).
198    pub fn extract_from_path(&self, path: &str) -> Option<ApiVersion> {
199        ApiVersion::from_path(path)
200    }
201
202    /// Extract version from a header value.
203    pub fn extract_from_header(&self, value: &str) -> Option<ApiVersion> {
204        ApiVersion::from_header_value(value)
205    }
206
207    /// Generate deprecation warning header value for a deprecated version.
208    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}