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