rustapi_openapi/versioning/
strategy.rs

1//! Version extraction strategies
2//!
3//! Provides different strategies for extracting API versions from requests.
4
5use super::version::ApiVersion;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Strategy for extracting API version from requests
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub enum VersionStrategy {
12    /// Extract version from URL path (e.g., /v1/users)
13    ///
14    /// The pattern should include `{version}` placeholder
15    /// Example: "/v{version}/" or "/{version}/"
16    Path {
17        /// Pattern for matching version in path
18        pattern: String,
19    },
20
21    /// Extract version from HTTP header
22    ///
23    /// Example: X-API-Version: 1.0
24    Header {
25        /// Header name to read version from
26        name: String,
27    },
28
29    /// Extract version from query parameter
30    ///
31    /// Example: ?version=1.0 or ?api-version=1.0
32    Query {
33        /// Query parameter name
34        param: String,
35    },
36
37    /// Extract version from Accept header media type
38    ///
39    /// Example: Accept: application/vnd.api.v1+json
40    Accept {
41        /// Media type pattern with version placeholder
42        /// Example: "application/vnd.{vendor}.v{version}+json"
43        pattern: String,
44    },
45
46    /// Use a custom extractor function
47    ///
48    /// Uses a named custom extractor registered with the router
49    Custom {
50        /// Name of the custom extractor
51        name: String,
52    },
53}
54
55impl VersionStrategy {
56    /// Create a path-based versioning strategy
57    ///
58    /// Default pattern: "/v{version}/"
59    pub fn path() -> Self {
60        Self::Path {
61            pattern: "/v{version}/".to_string(),
62        }
63    }
64
65    /// Create a path strategy with custom pattern
66    pub fn path_with_pattern(pattern: impl Into<String>) -> Self {
67        Self::Path {
68            pattern: pattern.into(),
69        }
70    }
71
72    /// Create a header-based versioning strategy
73    ///
74    /// Default header: "X-API-Version"
75    pub fn header() -> Self {
76        Self::Header {
77            name: "X-API-Version".to_string(),
78        }
79    }
80
81    /// Create a header strategy with custom header name
82    pub fn header_with_name(name: impl Into<String>) -> Self {
83        Self::Header { name: name.into() }
84    }
85
86    /// Create a query parameter versioning strategy
87    ///
88    /// Default parameter: "version"
89    pub fn query() -> Self {
90        Self::Query {
91            param: "version".to_string(),
92        }
93    }
94
95    /// Create a query strategy with custom parameter name
96    pub fn query_with_param(param: impl Into<String>) -> Self {
97        Self::Query {
98            param: param.into(),
99        }
100    }
101
102    /// Create an Accept header versioning strategy
103    ///
104    /// Default pattern: "application/vnd.api.v{version}+json"
105    pub fn accept() -> Self {
106        Self::Accept {
107            pattern: "application/vnd.api.v{version}+json".to_string(),
108        }
109    }
110
111    /// Create an Accept strategy with custom pattern
112    pub fn accept_with_pattern(pattern: impl Into<String>) -> Self {
113        Self::Accept {
114            pattern: pattern.into(),
115        }
116    }
117
118    /// Create a custom extraction strategy
119    pub fn custom(name: impl Into<String>) -> Self {
120        Self::Custom { name: name.into() }
121    }
122}
123
124impl Default for VersionStrategy {
125    fn default() -> Self {
126        Self::path()
127    }
128}
129
130/// Version extractor that can extract versions from request data
131#[derive(Debug, Clone)]
132pub struct VersionExtractor {
133    /// Strategies to try in order
134    strategies: Vec<VersionStrategy>,
135    /// Default version if none can be extracted
136    default: ApiVersion,
137}
138
139impl VersionExtractor {
140    /// Create a new extractor with default settings
141    pub fn new() -> Self {
142        Self {
143            strategies: vec![VersionStrategy::path()],
144            default: ApiVersion::v1(),
145        }
146    }
147
148    /// Create an extractor with a single strategy
149    pub fn with_strategy(strategy: VersionStrategy) -> Self {
150        Self {
151            strategies: vec![strategy],
152            default: ApiVersion::v1(),
153        }
154    }
155
156    /// Create an extractor with multiple strategies (tried in order)
157    pub fn with_strategies(strategies: Vec<VersionStrategy>) -> Self {
158        Self {
159            strategies,
160            default: ApiVersion::v1(),
161        }
162    }
163
164    /// Set the default version
165    pub fn default_version(mut self, version: ApiVersion) -> Self {
166        self.default = version;
167        self
168    }
169
170    /// Add a strategy to try
171    pub fn add_strategy(mut self, strategy: VersionStrategy) -> Self {
172        self.strategies.push(strategy);
173        self
174    }
175
176    /// Extract version from path
177    pub fn extract_from_path(&self, path: &str) -> Option<ApiVersion> {
178        for strategy in &self.strategies {
179            if let VersionStrategy::Path { pattern } = strategy {
180                if let Some(version) = Self::extract_path_version(path, pattern) {
181                    return Some(version);
182                }
183            }
184        }
185        None
186    }
187
188    /// Extract version from headers
189    pub fn extract_from_headers(&self, headers: &HashMap<String, String>) -> Option<ApiVersion> {
190        for strategy in &self.strategies {
191            match strategy {
192                VersionStrategy::Header { name } => {
193                    if let Some(value) = headers.get(&name.to_lowercase()) {
194                        if let Ok(version) = value.parse() {
195                            return Some(version);
196                        }
197                    }
198                }
199                VersionStrategy::Accept { pattern } => {
200                    if let Some(accept) = headers.get("accept") {
201                        if let Some(version) = Self::extract_accept_version(accept, pattern) {
202                            return Some(version);
203                        }
204                    }
205                }
206                _ => {}
207            }
208        }
209        None
210    }
211
212    /// Extract version from query string
213    pub fn extract_from_query(&self, query: &str) -> Option<ApiVersion> {
214        let params: HashMap<_, _> = query
215            .split('&')
216            .filter_map(|pair| {
217                let mut parts = pair.splitn(2, '=');
218                Some((parts.next()?.to_string(), parts.next()?.to_string()))
219            })
220            .collect();
221
222        for strategy in &self.strategies {
223            if let VersionStrategy::Query { param } = strategy {
224                if let Some(value) = params.get(param) {
225                    if let Ok(version) = value.parse() {
226                        return Some(version);
227                    }
228                }
229            }
230        }
231        None
232    }
233
234    /// Get the default version
235    pub fn get_default(&self) -> ApiVersion {
236        self.default
237    }
238
239    /// Extract version from path using pattern
240    fn extract_path_version(path: &str, pattern: &str) -> Option<ApiVersion> {
241        // Find the version placeholder position
242        let before = pattern.split("{version}").next()?;
243        let after = pattern.split("{version}").nth(1)?;
244
245        // Find the version segment in the path
246        if let Some(start) = path.find(before) {
247            let version_start = start + before.len();
248            let remaining = &path[version_start..];
249
250            // Find the end of the version segment
251            let version_end = if after.is_empty() {
252                remaining.len()
253            } else {
254                remaining.find(after).unwrap_or(remaining.len())
255            };
256
257            let version_str = &remaining[..version_end];
258            version_str.parse().ok()
259        } else {
260            None
261        }
262    }
263
264    /// Extract version from Accept header
265    fn extract_accept_version(accept: &str, pattern: &str) -> Option<ApiVersion> {
266        // Parse the pattern
267        let before = pattern.split("{version}").next()?;
268        let after = pattern.split("{version}").nth(1)?;
269
270        // Find in accept header
271        for media_type in accept.split(',').map(|s| s.trim()) {
272            if let Some(start) = media_type.find(before) {
273                let version_start = start + before.len();
274                let remaining = &media_type[version_start..];
275
276                let version_end = if after.is_empty() {
277                    remaining.len()
278                } else {
279                    remaining.find(after).unwrap_or(remaining.len())
280                };
281
282                let version_str = &remaining[..version_end];
283                if let Ok(version) = version_str.parse() {
284                    return Some(version);
285                }
286            }
287        }
288        None
289    }
290
291    /// Remove version from path, returning the path without version prefix/suffix
292    pub fn strip_version_from_path(&self, path: &str) -> String {
293        for strategy in &self.strategies {
294            if let VersionStrategy::Path { pattern } = strategy {
295                if let Some(stripped) = Self::strip_path_version(path, pattern) {
296                    return stripped;
297                }
298            }
299        }
300        path.to_string()
301    }
302
303    /// Strip version from path using pattern
304    fn strip_path_version(path: &str, pattern: &str) -> Option<String> {
305        let before = pattern.split("{version}").next()?;
306        let after = pattern.split("{version}").nth(1)?;
307
308        if let Some(start) = path.find(before) {
309            let version_start = start + before.len();
310            let remaining = &path[version_start..];
311
312            let version_end = if after.is_empty() {
313                remaining.len()
314            } else {
315                remaining.find(after)?
316            };
317
318            // Verify it's a valid version
319            let version_str = &remaining[..version_end];
320            if version_str.parse::<ApiVersion>().is_ok() {
321                let prefix = &path[..start];
322                // The suffix starts after version_end + after.len() in remaining
323                // But we want to keep the leading / for paths
324                let suffix = &remaining[version_end + after.len()..];
325                // Ensure result starts with / if original path did and prefix is empty
326                if path.starts_with('/') && prefix.is_empty() && !suffix.starts_with('/') {
327                    return Some(format!("/{}", suffix));
328                }
329                return Some(format!("{}{}", prefix, suffix));
330            }
331        }
332        None
333    }
334}
335
336impl Default for VersionExtractor {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342/// Result of version extraction
343#[derive(Debug, Clone)]
344pub struct ExtractedVersion {
345    /// The extracted version
346    pub version: ApiVersion,
347    /// Source of the version
348    pub source: VersionSource,
349}
350
351/// Source from which version was extracted
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum VersionSource {
354    /// Extracted from URL path
355    Path,
356    /// Extracted from HTTP header
357    Header,
358    /// Extracted from query parameter
359    Query,
360    /// Extracted from Accept header
361    Accept,
362    /// Default version was used
363    Default,
364    /// Custom extraction
365    Custom,
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_extract_from_path() {
374        let extractor = VersionExtractor::new();
375
376        assert_eq!(
377            extractor.extract_from_path("/v1/users"),
378            Some(ApiVersion::major(1))
379        );
380        assert_eq!(
381            extractor.extract_from_path("/v2/products/123"),
382            Some(ApiVersion::major(2))
383        );
384        assert_eq!(
385            extractor.extract_from_path("/v1.2/items"),
386            Some(ApiVersion::new(1, 2, 0))
387        );
388    }
389
390    #[test]
391    fn test_extract_from_header() {
392        let extractor = VersionExtractor::with_strategy(VersionStrategy::header());
393        let mut headers = HashMap::new();
394        headers.insert("x-api-version".to_string(), "2.0".to_string());
395
396        assert_eq!(
397            extractor.extract_from_headers(&headers),
398            Some(ApiVersion::new(2, 0, 0))
399        );
400    }
401
402    #[test]
403    fn test_extract_from_query() {
404        let extractor = VersionExtractor::with_strategy(VersionStrategy::query());
405
406        assert_eq!(
407            extractor.extract_from_query("version=1&other=value"),
408            Some(ApiVersion::major(1))
409        );
410        assert_eq!(
411            extractor.extract_from_query("foo=bar&version=2.1"),
412            Some(ApiVersion::new(2, 1, 0))
413        );
414    }
415
416    #[test]
417    fn test_extract_from_accept() {
418        let extractor = VersionExtractor::with_strategy(VersionStrategy::accept());
419        let mut headers = HashMap::new();
420        headers.insert(
421            "accept".to_string(),
422            "application/vnd.api.v2+json".to_string(),
423        );
424
425        assert_eq!(
426            extractor.extract_from_headers(&headers),
427            Some(ApiVersion::major(2))
428        );
429    }
430
431    #[test]
432    fn test_strip_version_from_path() {
433        let extractor = VersionExtractor::new();
434
435        assert_eq!(extractor.strip_version_from_path("/v1/users"), "/users");
436        assert_eq!(
437            extractor.strip_version_from_path("/v2.0/products/123"),
438            "/products/123"
439        );
440    }
441
442    #[test]
443    fn test_multiple_strategies() {
444        let extractor = VersionExtractor::with_strategies(vec![
445            VersionStrategy::path(),
446            VersionStrategy::header(),
447            VersionStrategy::query(),
448        ])
449        .default_version(ApiVersion::v1());
450
451        // Path takes precedence
452        assert_eq!(
453            extractor.extract_from_path("/v2/test"),
454            Some(ApiVersion::major(2))
455        );
456
457        // Falls back to query
458        assert_eq!(
459            extractor.extract_from_query("version=3"),
460            Some(ApiVersion::major(3))
461        );
462    }
463
464    #[test]
465    fn test_custom_path_pattern() {
466        let extractor =
467            VersionExtractor::with_strategy(VersionStrategy::path_with_pattern("/api/{version}/"));
468
469        assert_eq!(
470            extractor.extract_from_path("/api/1/users"),
471            Some(ApiVersion::major(1))
472        );
473        assert_eq!(
474            extractor.extract_from_path("/api/2.0/products"),
475            Some(ApiVersion::new(2, 0, 0))
476        );
477    }
478}