oxidite_core/
versioning.rs

1//! API Versioning support
2
3use std::collections::HashMap;
4use crate::{Router, OxiditeRequest, OxiditeResponse};
5
6/// API version
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum ApiVersion {
9    V1,
10    V2,
11    V3,
12    Custom(u8),
13}
14
15impl ApiVersion {
16    pub fn from_str(s: &str) -> Option<Self> {
17        match s {
18            "v1" | "1" => Some(ApiVersion::V1),
19            "v2" | "2" => Some(ApiVersion::V2),
20            "v3" | "3" => Some(ApiVersion::V3),
21            _ => s.trim_start_matches('v').parse::<u8>().ok().map(ApiVersion::Custom),
22        }
23    }
24    
25    pub fn as_str(&self) -> &'static str {
26        match self {
27            ApiVersion::V1 => "v1",
28            ApiVersion::V2 => "v2",
29            ApiVersion::V3 => "v3",
30            ApiVersion::Custom(_) => "custom",
31        }
32    }
33}
34
35/// Versioned router
36pub struct VersionedRouter {
37    routers: HashMap<ApiVersion, Router>,
38    default_version: ApiVersion,
39}
40
41impl VersionedRouter {
42    pub fn new(default_version: ApiVersion) -> Self {
43        Self {
44            routers: HashMap::new(),
45            default_version,
46        }
47    }
48    
49    /// Add a router for a specific version
50    pub fn version(&mut self, version: ApiVersion, router: Router) {
51        self.routers.insert(version, router);
52    }
53    
54    /// Extract version from request
55    /// Supports:
56    /// - URL path: /api/v1/users
57    /// - Header: Accept: application/vnd.api+json;version=1
58    /// - Query param: /api/users?version=1
59    pub fn extract_version(&self, req: &OxiditeRequest) -> ApiVersion {
60        // Try URL path first
61        if let Some(path) = req.uri().path().split('/').find(|s| s.starts_with('v')) {
62            if let Some(version) = ApiVersion::from_str(path) {
63                return version;
64            }
65        }
66        
67        // Try Accept header
68        if let Some(accept) = req.headers().get("accept") {
69            if let Ok(accept_str) = accept.to_str() {
70                if let Some(version_part) = accept_str.split(";version=").nth(1) {
71                    if let Some(version) = ApiVersion::from_str(version_part.split(',').next().unwrap_or("")) {
72                        return version;
73                    }
74                }
75            }
76        }
77        
78        // Try query parameter
79        if let Some(query) = req.uri().query() {
80            for pair in query.split('&') {
81                if let Some((key, value)) = pair.split_once('=') {
82                    if key == "version" {
83                        if let Some(version) = ApiVersion::from_str(value) {
84                            return version;
85                        }
86                    }
87                }
88            }
89        }
90        
91        // Return default
92        self.default_version
93    }
94    
95    /// Get router for version
96    pub fn get_router(&self, version: ApiVersion) -> Option<&Router> {
97        self.routers.get(&version)
98    }
99}
100
101/// Version deprecation middleware
102pub struct DeprecationMiddleware {
103    deprecated_versions: Vec<ApiVersion>,
104    sunset_date: Option<String>,
105}
106
107impl DeprecationMiddleware {
108    pub fn new(deprecated_versions: Vec<ApiVersion>) -> Self {
109        Self {
110            deprecated_versions,
111            sunset_date: None,
112        }
113    }
114    
115    pub fn with_sunset_date(mut self, date: String) -> Self {
116        self.sunset_date = Some(date);
117        self
118    }
119    
120    /// Add deprecation headers to response
121    pub fn add_headers(&self, version: ApiVersion, response: &mut OxiditeResponse) {
122        if self.deprecated_versions.contains(&version) {
123            response.headers_mut().insert(
124                "Deprecation",
125                "true".parse().unwrap()
126            );
127            
128            if let Some(date) = &self.sunset_date {
129                response.headers_mut().insert(
130                    "Sunset",
131                    date.parse().unwrap()
132                );
133            }
134            
135            response.headers_mut().insert(
136                "Link",
137                format!("</api/docs>; rel=\"deprecation\"").parse().unwrap()
138            );
139        }
140    }
141}