Skip to main content

rustapi_openapi/versioning/
router.rs

1//! Version-based routing
2//!
3//! Provides routing infrastructure for versioned APIs.
4
5use super::strategy::{VersionExtractor, VersionStrategy};
6use super::version::{ApiVersion, VersionRange};
7use crate::OpenApiSpec;
8use std::collections::HashMap;
9
10/// Configuration for a versioned route
11#[derive(Debug, Clone)]
12pub struct VersionedRouteConfig {
13    /// Version matcher for this route
14    pub matcher: VersionRange,
15    /// Whether this version is deprecated
16    pub deprecated: bool,
17    /// Deprecation message
18    pub deprecation_message: Option<String>,
19    /// Sunset date (RFC 3339)
20    pub sunset: Option<String>,
21}
22
23impl VersionedRouteConfig {
24    /// Create a new route config for a specific version
25    pub fn version(version: ApiVersion) -> Self {
26        Self {
27            matcher: VersionRange::exact(version),
28            deprecated: false,
29            deprecation_message: None,
30            sunset: None,
31        }
32    }
33
34    /// Create a route config for a version range
35    pub fn range(range: VersionRange) -> Self {
36        Self {
37            matcher: range,
38            deprecated: false,
39            deprecation_message: None,
40            sunset: None,
41        }
42    }
43
44    /// Mark this version as deprecated
45    pub fn deprecated(mut self) -> Self {
46        self.deprecated = true;
47        self
48    }
49
50    /// Add a deprecation message
51    pub fn with_deprecation_message(mut self, message: impl Into<String>) -> Self {
52        self.deprecated = true;
53        self.deprecation_message = Some(message.into());
54        self
55    }
56
57    /// Set a sunset date
58    pub fn with_sunset(mut self, date: impl Into<String>) -> Self {
59        self.sunset = Some(date.into());
60        self
61    }
62
63    /// Check if this config matches a version
64    pub fn matches(&self, version: &ApiVersion) -> bool {
65        self.matcher.contains(version)
66    }
67}
68
69/// Router for version-based API routing
70///
71/// This router manages different versions of your API and can:
72/// - Route requests to the appropriate version handler
73/// - Generate separate OpenAPI specs for each version
74/// - Handle version deprecation and sunset
75#[derive(Debug, Clone)]
76pub struct VersionRouter {
77    /// Version extraction strategy
78    extractor: VersionExtractor,
79    /// Registered versions with their specs
80    versions: HashMap<ApiVersion, VersionInfo>,
81    /// Default version to use when none specified
82    default_version: ApiVersion,
83    /// Fallback behavior
84    fallback: VersionFallback,
85}
86
87/// Information about a version
88#[derive(Debug, Clone)]
89struct VersionInfo {
90    /// Route configuration
91    config: VersionedRouteConfig,
92    /// OpenAPI spec for this version (3.1)
93    spec_31: Option<OpenApiSpec>,
94    /// OpenAPI spec for this version (3.0 legacy field, now uses 3.1 type)
95    spec_30: Option<OpenApiSpec>,
96}
97
98/// Fallback behavior when version is not found
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum VersionFallback {
101    /// Use the default version
102    #[default]
103    Default,
104    /// Use the latest version
105    Latest,
106    /// Return an error
107    Error,
108}
109
110impl VersionRouter {
111    /// Create a new version router
112    pub fn new() -> Self {
113        Self {
114            extractor: VersionExtractor::new(),
115            versions: HashMap::new(),
116            default_version: ApiVersion::v1(),
117            fallback: VersionFallback::Default,
118        }
119    }
120
121    /// Set the versioning strategy
122    pub fn strategy(mut self, strategy: VersionStrategy) -> Self {
123        self.extractor = VersionExtractor::with_strategy(strategy);
124        self
125    }
126
127    /// Add multiple strategies (tried in order)
128    pub fn strategies(mut self, strategies: Vec<VersionStrategy>) -> Self {
129        self.extractor = VersionExtractor::with_strategies(strategies);
130        self
131    }
132
133    /// Set the default version
134    pub fn default_version(mut self, version: ApiVersion) -> Self {
135        self.default_version = version;
136        self.extractor = self.extractor.default_version(version);
137        self
138    }
139
140    /// Set the fallback behavior
141    pub fn fallback(mut self, behavior: VersionFallback) -> Self {
142        self.fallback = behavior;
143        self
144    }
145
146    /// Register a version
147    pub fn version(mut self, version: ApiVersion, config: VersionedRouteConfig) -> Self {
148        self.versions.insert(
149            version,
150            VersionInfo {
151                config,
152                spec_31: None,
153                spec_30: None,
154            },
155        );
156        self
157    }
158
159    /// Register a version with OpenAPI 3.1 spec
160    pub fn version_with_spec_31(
161        mut self,
162        version: ApiVersion,
163        config: VersionedRouteConfig,
164        spec: OpenApiSpec,
165    ) -> Self {
166        self.versions.insert(
167            version,
168            VersionInfo {
169                config,
170                spec_31: Some(spec),
171                spec_30: None,
172            },
173        );
174        self
175    }
176
177    /// Register a version with OpenAPI 3.0 spec
178    pub fn version_with_spec_30(
179        mut self,
180        version: ApiVersion,
181        config: VersionedRouteConfig,
182        spec: OpenApiSpec,
183    ) -> Self {
184        self.versions.insert(
185            version,
186            VersionInfo {
187                config,
188                spec_31: None,
189                spec_30: Some(spec),
190            },
191        );
192        self
193    }
194
195    /// Get all registered versions
196    pub fn registered_versions(&self) -> Vec<ApiVersion> {
197        let mut versions: Vec<_> = self.versions.keys().copied().collect();
198        versions.sort();
199        versions
200    }
201
202    /// Get the latest registered version
203    pub fn latest_version(&self) -> Option<ApiVersion> {
204        self.registered_versions().into_iter().max()
205    }
206
207    /// Resolve a version from a path
208    pub fn resolve_from_path(&self, path: &str) -> ResolvedVersion {
209        if let Some(version) = self.extractor.extract_from_path(path) {
210            self.resolve_version(version)
211        } else {
212            self.resolve_fallback()
213        }
214    }
215
216    /// Resolve a version from headers
217    pub fn resolve_from_headers(&self, headers: &HashMap<String, String>) -> ResolvedVersion {
218        if let Some(version) = self.extractor.extract_from_headers(headers) {
219            self.resolve_version(version)
220        } else {
221            self.resolve_fallback()
222        }
223    }
224
225    /// Resolve a version from query string
226    pub fn resolve_from_query(&self, query: &str) -> ResolvedVersion {
227        if let Some(version) = self.extractor.extract_from_query(query) {
228            self.resolve_version(version)
229        } else {
230            self.resolve_fallback()
231        }
232    }
233
234    /// Resolve a specific version
235    fn resolve_version(&self, version: ApiVersion) -> ResolvedVersion {
236        // Check for exact match
237        if let Some(info) = self.versions.get(&version) {
238            return ResolvedVersion {
239                version,
240                found: true,
241                deprecated: info.config.deprecated,
242                deprecation_message: info.config.deprecation_message.clone(),
243                sunset: info.config.sunset.clone(),
244            };
245        }
246
247        // Check for range match
248        for (v, info) in &self.versions {
249            if info.config.matches(&version) {
250                return ResolvedVersion {
251                    version: *v,
252                    found: true,
253                    deprecated: info.config.deprecated,
254                    deprecation_message: info.config.deprecation_message.clone(),
255                    sunset: info.config.sunset.clone(),
256                };
257            }
258        }
259
260        // Not found, use fallback
261        self.resolve_fallback()
262    }
263
264    /// Resolve using fallback behavior
265    fn resolve_fallback(&self) -> ResolvedVersion {
266        match self.fallback {
267            VersionFallback::Default => {
268                let info = self.versions.get(&self.default_version);
269                ResolvedVersion {
270                    version: self.default_version,
271                    found: info.is_some(),
272                    deprecated: info.map(|i| i.config.deprecated).unwrap_or(false),
273                    deprecation_message: info.and_then(|i| i.config.deprecation_message.clone()),
274                    sunset: info.and_then(|i| i.config.sunset.clone()),
275                }
276            }
277            VersionFallback::Latest => {
278                if let Some(version) = self.latest_version() {
279                    let info = self.versions.get(&version);
280                    ResolvedVersion {
281                        version,
282                        found: true,
283                        deprecated: info.map(|i| i.config.deprecated).unwrap_or(false),
284                        deprecation_message: info
285                            .and_then(|i| i.config.deprecation_message.clone()),
286                        sunset: info.and_then(|i| i.config.sunset.clone()),
287                    }
288                } else {
289                    ResolvedVersion {
290                        version: self.default_version,
291                        found: false,
292                        deprecated: false,
293                        deprecation_message: None,
294                        sunset: None,
295                    }
296                }
297            }
298            VersionFallback::Error => ResolvedVersion {
299                version: self.default_version,
300                found: false,
301                deprecated: false,
302                deprecation_message: None,
303                sunset: None,
304            },
305        }
306    }
307
308    /// Get OpenAPI 3.1 spec for a version
309    pub fn get_spec_31(&self, version: &ApiVersion) -> Option<&OpenApiSpec> {
310        self.versions.get(version).and_then(|v| v.spec_31.as_ref())
311    }
312
313    /// Get OpenAPI 3.0 spec for a version
314    pub fn get_spec_30(&self, version: &ApiVersion) -> Option<&OpenApiSpec> {
315        self.versions.get(version).and_then(|v| v.spec_30.as_ref())
316    }
317
318    /// Strip version from path
319    pub fn strip_version(&self, path: &str) -> String {
320        self.extractor.strip_version_from_path(path)
321    }
322
323    /// Check if a version is deprecated
324    pub fn is_deprecated(&self, version: &ApiVersion) -> bool {
325        self.versions
326            .get(version)
327            .map(|v| v.config.deprecated)
328            .unwrap_or(false)
329    }
330
331    /// Get deprecation info for a version
332    pub fn get_deprecation_info(&self, version: &ApiVersion) -> Option<DeprecationInfo> {
333        self.versions.get(version).and_then(|v| {
334            if v.config.deprecated {
335                Some(DeprecationInfo {
336                    message: v.config.deprecation_message.clone(),
337                    sunset: v.config.sunset.clone(),
338                })
339            } else {
340                None
341            }
342        })
343    }
344}
345
346impl Default for VersionRouter {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352/// Result of version resolution
353#[derive(Debug, Clone)]
354pub struct ResolvedVersion {
355    /// The resolved version
356    pub version: ApiVersion,
357    /// Whether the version was found
358    pub found: bool,
359    /// Whether the version is deprecated
360    pub deprecated: bool,
361    /// Deprecation message
362    pub deprecation_message: Option<String>,
363    /// Sunset date
364    pub sunset: Option<String>,
365}
366
367impl ResolvedVersion {
368    /// Get HTTP headers for this resolved version
369    pub fn response_headers(&self) -> HashMap<String, String> {
370        let mut headers = HashMap::new();
371
372        // Add API-Version header
373        headers.insert("API-Version".to_string(), self.version.to_string());
374
375        // Add deprecation headers if deprecated
376        if self.deprecated {
377            headers.insert("Deprecation".to_string(), "true".to_string());
378
379            if let Some(sunset) = &self.sunset {
380                headers.insert("Sunset".to_string(), sunset.clone());
381            }
382
383            if let Some(message) = &self.deprecation_message {
384                headers.insert("X-Deprecation-Notice".to_string(), message.clone());
385            }
386        }
387
388        headers
389    }
390}
391
392/// Deprecation information
393#[derive(Debug, Clone)]
394pub struct DeprecationInfo {
395    /// Deprecation message
396    pub message: Option<String>,
397    /// Sunset date (RFC 3339)
398    pub sunset: Option<String>,
399}
400
401/// Builder for creating versioned OpenAPI specs
402pub struct VersionedSpecBuilder {
403    /// Base title
404    title: String,
405    /// Base description
406    description: Option<String>,
407    /// Versions to build
408    versions: Vec<(ApiVersion, VersionedRouteConfig)>,
409}
410
411impl VersionedSpecBuilder {
412    /// Create a new builder
413    pub fn new(title: impl Into<String>) -> Self {
414        Self {
415            title: title.into(),
416            description: None,
417            versions: Vec::new(),
418        }
419    }
420
421    /// Set description
422    pub fn description(mut self, desc: impl Into<String>) -> Self {
423        self.description = Some(desc.into());
424        self
425    }
426
427    /// Add a version
428    pub fn version(mut self, version: ApiVersion, config: VersionedRouteConfig) -> Self {
429        self.versions.push((version, config));
430        self
431    }
432
433    /// Build OpenAPI 3.1 specs for all versions
434    pub fn build_31(&self) -> HashMap<ApiVersion, OpenApiSpec> {
435        let mut specs = HashMap::new();
436
437        for (version, config) in &self.versions {
438            let mut spec = OpenApiSpec::new(
439                format!("{} {}", self.title, version.as_path_segment()),
440                version.to_string(),
441            );
442
443            if let Some(desc) = &self.description {
444                spec = spec.description(desc.clone());
445            }
446
447            // Add deprecation info
448            if config.deprecated {
449                let mut info = "DEPRECATED".to_string();
450                if let Some(msg) = &config.deprecation_message {
451                    info.push_str(&format!(": {}", msg));
452                }
453                if let Some(sunset) = &config.sunset {
454                    info.push_str(&format!(" (Sunset: {})", sunset));
455                }
456                spec.info.summary = Some(info);
457            }
458
459            specs.insert(*version, spec);
460        }
461
462        specs
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_router_creation() {
472        let router = VersionRouter::new()
473            .strategy(VersionStrategy::path())
474            .default_version(ApiVersion::v1())
475            .version(
476                ApiVersion::v1(),
477                VersionedRouteConfig::version(ApiVersion::v1()),
478            )
479            .version(
480                ApiVersion::v2(),
481                VersionedRouteConfig::version(ApiVersion::v2()).deprecated(),
482            );
483
484        assert_eq!(
485            router.registered_versions(),
486            vec![ApiVersion::v1(), ApiVersion::v2()]
487        );
488        assert!(!router.is_deprecated(&ApiVersion::v1()));
489        assert!(router.is_deprecated(&ApiVersion::v2()));
490    }
491
492    #[test]
493    fn test_resolve_from_path() {
494        let router = VersionRouter::new()
495            .version(
496                ApiVersion::v1(),
497                VersionedRouteConfig::version(ApiVersion::v1()),
498            )
499            .version(
500                ApiVersion::v2(),
501                VersionedRouteConfig::version(ApiVersion::v2()),
502            );
503
504        let resolved = router.resolve_from_path("/v1/users");
505        assert!(resolved.found);
506        assert_eq!(resolved.version, ApiVersion::v1());
507
508        let resolved = router.resolve_from_path("/v2/products");
509        assert!(resolved.found);
510        assert_eq!(resolved.version, ApiVersion::v2());
511    }
512
513    #[test]
514    fn test_resolve_fallback() {
515        let router = VersionRouter::new()
516            .default_version(ApiVersion::v1())
517            .fallback(VersionFallback::Default)
518            .version(
519                ApiVersion::v1(),
520                VersionedRouteConfig::version(ApiVersion::v1()),
521            );
522
523        // v3 not registered, should fall back to default
524        let resolved = router.resolve_from_path("/v3/test");
525        assert_eq!(resolved.version, ApiVersion::v1());
526    }
527
528    #[test]
529    fn test_deprecation_info() {
530        let router = VersionRouter::new().version(
531            ApiVersion::v1(),
532            VersionedRouteConfig::version(ApiVersion::v1())
533                .with_deprecation_message("Use v2 instead")
534                .with_sunset("2024-12-31T23:59:59Z"),
535        );
536
537        let info = router.get_deprecation_info(&ApiVersion::v1()).unwrap();
538        assert_eq!(info.message, Some("Use v2 instead".to_string()));
539        assert_eq!(info.sunset, Some("2024-12-31T23:59:59Z".to_string()));
540    }
541
542    #[test]
543    fn test_response_headers() {
544        let resolved = ResolvedVersion {
545            version: ApiVersion::v1(),
546            found: true,
547            deprecated: true,
548            deprecation_message: Some("Legacy version".to_string()),
549            sunset: Some("2024-12-31".to_string()),
550        };
551
552        let headers = resolved.response_headers();
553        assert_eq!(headers.get("API-Version"), Some(&"1.0.0".to_string()));
554        assert_eq!(headers.get("Deprecation"), Some(&"true".to_string()));
555        assert_eq!(headers.get("Sunset"), Some(&"2024-12-31".to_string()));
556    }
557
558    #[test]
559    fn test_versioned_spec_builder() {
560        let specs = VersionedSpecBuilder::new("My API")
561            .description("API description")
562            .version(
563                ApiVersion::v1(),
564                VersionedRouteConfig::version(ApiVersion::v1()),
565            )
566            .version(
567                ApiVersion::v2(),
568                VersionedRouteConfig::version(ApiVersion::v2()),
569            )
570            .build_31();
571
572        assert_eq!(specs.len(), 2);
573        assert!(specs.contains_key(&ApiVersion::v1()));
574        assert!(specs.contains_key(&ApiVersion::v2()));
575    }
576}