//! API versioning support. //! //! Implements multiple versioning strategies: //! - URL path versioning (/v1/..., /v2/...) //! - Header-based versioning (Accept-Version, X-API-Version) //! - Query parameter versioning (?version=1) //! //! ## Deprecation Policy //! //! - Major versions are supported for 12 months after deprecation //! - Deprecated versions return `Deprecation` and `Sunset` headers //! - Breaking changes only in major version bumps use axum::{ extract::Request, http::{header::HeaderName, HeaderValue, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; // ============================================================================ // Version Types // ============================================================================ /// API version identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ApiVersion { /// Major version (breaking changes) pub major: u32, /// Minor version (new features, backwards compatible) pub minor: u32, } impl ApiVersion { /// Create a new API version. pub const fn new(major: u32, minor: u32) -> Self { Self { major, minor } } /// Parse from string like "1", "1.0", "v1", "v1.2". pub fn parse(s: &str) -> Option { let s = s.trim().trim_start_matches('v').trim_start_matches('V'); if let Some((major, minor)) = s.split_once('.') { let major = major.parse().ok()?; let minor = minor.parse().ok()?; Some(Self { major, minor }) } else { let major = s.parse().ok()?; Some(Self { major, minor: 0 }) } } /// Check if this version is compatible with another. pub fn is_compatible_with(&self, other: &Self) -> bool { self.major == other.major && self.minor >= other.minor } } impl std::fmt::Display for ApiVersion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}", self.major, self.minor) } } impl Default for ApiVersion { fn default() -> Self { Self::new(1, 0) } } // ============================================================================ // Version Constants // ============================================================================ /// Current API version. pub const CURRENT_VERSION: ApiVersion = ApiVersion::new(1, 0); /// Minimum supported version. pub const MIN_SUPPORTED_VERSION: ApiVersion = ApiVersion::new(1, 0); /// Version currently in development (preview). pub const PREVIEW_VERSION: ApiVersion = ApiVersion::new(2, 0); // ============================================================================ // Version Info // ============================================================================ /// Information about a specific API version. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionInfo { /// The version identifier pub version: ApiVersion, /// Whether this version is the current default pub is_current: bool, /// Whether this version is deprecated pub is_deprecated: bool, /// Deprecation date (if deprecated) pub deprecated_at: Option>, /// Sunset date (when version will be removed) pub sunset_at: Option>, /// Release notes URL pub release_notes_url: Option, /// Changes from previous version pub changes: Vec, } /// API version registry. #[derive(Debug, Clone)] pub struct VersionRegistry { versions: HashMap<(u32, u32), VersionInfo>, current: ApiVersion, } impl Default for VersionRegistry { fn default() -> Self { Self::new() } } impl VersionRegistry { /// Create a new version registry with standard versions. pub fn new() -> Self { let mut registry = Self { versions: HashMap::new(), current: CURRENT_VERSION, }; // Register v1.0 (current) registry.register(VersionInfo { version: ApiVersion::new(1, 0), is_current: true, is_deprecated: false, deprecated_at: None, sunset_at: None, release_notes_url: Some("https://docs.synor.io/api/v1/release-notes".to_string()), changes: vec![ "Initial API release".to_string(), "Wallet, RPC, Storage, DEX, IBC, ZK services".to_string(), "WebSocket event streaming".to_string(), ], }); // Register v2.0 (preview) registry.register(VersionInfo { version: ApiVersion::new(2, 0), is_current: false, is_deprecated: false, deprecated_at: None, sunset_at: None, release_notes_url: None, changes: vec![ "Preview version - not for production use".to_string(), "GraphQL API support".to_string(), "Enhanced batch operations".to_string(), ], }); registry } /// Register a version. pub fn register(&mut self, info: VersionInfo) { self.versions.insert((info.version.major, info.version.minor), info); } /// Get version info. pub fn get(&self, version: &ApiVersion) -> Option<&VersionInfo> { self.versions.get(&(version.major, version.minor)) } /// Get the current version. pub fn current(&self) -> &ApiVersion { &self.current } /// Check if a version is supported. pub fn is_supported(&self, version: &ApiVersion) -> bool { self.versions.contains_key(&(version.major, version.minor)) && *version >= MIN_SUPPORTED_VERSION } /// Get all supported versions. pub fn supported_versions(&self) -> Vec<&VersionInfo> { self.versions .values() .filter(|v| v.version >= MIN_SUPPORTED_VERSION) .collect() } } // ============================================================================ // Version Extraction // ============================================================================ /// How the API version was specified. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VersionSource { /// From URL path (/v1/...) UrlPath, /// From Accept-Version header AcceptHeader, /// From X-API-Version header CustomHeader, /// From query parameter (?version=1) QueryParam, /// Default version (not explicitly specified) Default, } /// Extracted API version with metadata. #[derive(Debug, Clone)] pub struct ExtractedVersion { /// The API version pub version: ApiVersion, /// How it was determined pub source: VersionSource, } /// Extract API version from request. pub fn extract_version(req: &Request) -> ExtractedVersion { // 1. Check Accept-Version header if let Some(value) = req.headers().get("accept-version") { if let Ok(s) = value.to_str() { if let Some(version) = ApiVersion::parse(s) { return ExtractedVersion { version, source: VersionSource::AcceptHeader, }; } } } // 2. Check X-API-Version header if let Some(value) = req.headers().get("x-api-version") { if let Ok(s) = value.to_str() { if let Some(version) = ApiVersion::parse(s) { return ExtractedVersion { version, source: VersionSource::CustomHeader, }; } } } // 3. Check query parameter if let Some(query) = req.uri().query() { for pair in query.split('&') { if let Some((key, value)) = pair.split_once('=') { if key == "version" || key == "api_version" { if let Some(version) = ApiVersion::parse(value) { return ExtractedVersion { version, source: VersionSource::QueryParam, }; } } } } } // 4. Extract from URL path let path = req.uri().path(); if path.starts_with("/v") { if let Some(version_str) = path.split('/').nth(1) { if let Some(version) = ApiVersion::parse(version_str) { return ExtractedVersion { version, source: VersionSource::UrlPath, }; } } } // 5. Default version ExtractedVersion { version: CURRENT_VERSION, source: VersionSource::Default, } } // ============================================================================ // Versioning Middleware // ============================================================================ /// Custom header names. static X_API_VERSION: HeaderName = HeaderName::from_static("x-api-version"); static X_API_DEPRECATED: HeaderName = HeaderName::from_static("x-api-deprecated"); static DEPRECATION: HeaderName = HeaderName::from_static("deprecation"); static SUNSET: HeaderName = HeaderName::from_static("sunset"); /// Version validation middleware. pub async fn version_middleware(req: Request, next: Next) -> Response { let registry = VersionRegistry::new(); let extracted = extract_version(&req); // Check if version is supported if !registry.is_supported(&extracted.version) { return ( StatusCode::BAD_REQUEST, [(X_API_VERSION.clone(), HeaderValue::from_static("1.0"))], format!( "API version {} is not supported. Supported versions: {}", extracted.version, registry .supported_versions() .iter() .map(|v| v.version.to_string()) .collect::>() .join(", ") ), ) .into_response(); } // Continue with request let mut response = next.run(req).await; // Add version headers to response let headers = response.headers_mut(); // Always include current version if let Ok(v) = HeaderValue::from_str(&extracted.version.to_string()) { headers.insert(X_API_VERSION.clone(), v); } // Add deprecation headers if needed if let Some(info) = registry.get(&extracted.version) { if info.is_deprecated { headers.insert( X_API_DEPRECATED.clone(), HeaderValue::from_static("true"), ); if let Some(deprecated_at) = &info.deprecated_at { if let Ok(v) = HeaderValue::from_str(&deprecated_at.to_rfc3339()) { headers.insert(DEPRECATION.clone(), v); } } if let Some(sunset_at) = &info.sunset_at { if let Ok(v) = HeaderValue::from_str(&sunset_at.to_rfc3339()) { headers.insert(SUNSET.clone(), v); } } } } response } // ============================================================================ // Version Response Types // ============================================================================ /// Response for version information endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionsResponse { /// Current version pub current: String, /// All supported versions pub supported: Vec, /// Deprecated versions pub deprecated: Vec, } /// Detailed version information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionDetail { /// Version string pub version: String, /// Status (current, supported, deprecated, preview) pub status: String, /// When deprecated (if applicable) #[serde(skip_serializing_if = "Option::is_none")] pub deprecated_at: Option, /// When it will be removed (if applicable) #[serde(skip_serializing_if = "Option::is_none")] pub sunset_at: Option, /// URL to release notes #[serde(skip_serializing_if = "Option::is_none")] pub release_notes: Option, } impl VersionsResponse { /// Build version response from registry. pub fn from_registry(registry: &VersionRegistry) -> Self { let mut supported = Vec::new(); let mut deprecated = Vec::new(); for info in registry.supported_versions() { let detail = VersionDetail { version: info.version.to_string(), status: if info.is_current { "current".to_string() } else if info.is_deprecated { "deprecated".to_string() } else if info.version == PREVIEW_VERSION { "preview".to_string() } else { "supported".to_string() }, deprecated_at: info.deprecated_at.map(|d| d.to_rfc3339()), sunset_at: info.sunset_at.map(|d| d.to_rfc3339()), release_notes: info.release_notes_url.clone(), }; if info.is_deprecated { deprecated.push(detail); } else { supported.push(detail); } } Self { current: registry.current().to_string(), supported, deprecated, } } } // ============================================================================ // Routes // ============================================================================ use axum::{routing::get, Json, Router}; use crate::routes::AppState; /// Build version routes. pub fn router() -> Router { Router::new() .route("/versions", get(get_versions)) .route("/version", get(get_current_version)) } /// Get all API versions. async fn get_versions() -> Json { let registry = VersionRegistry::new(); Json(VersionsResponse::from_registry(®istry)) } /// Get current API version. async fn get_current_version() -> Json { Json(serde_json::json!({ "version": CURRENT_VERSION.to_string(), "major": CURRENT_VERSION.major, "minor": CURRENT_VERSION.minor, })) } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; #[test] fn test_version_parsing() { assert_eq!(ApiVersion::parse("1"), Some(ApiVersion::new(1, 0))); assert_eq!(ApiVersion::parse("1.0"), Some(ApiVersion::new(1, 0))); assert_eq!(ApiVersion::parse("v1"), Some(ApiVersion::new(1, 0))); assert_eq!(ApiVersion::parse("v2.1"), Some(ApiVersion::new(2, 1))); assert_eq!(ApiVersion::parse("V1.5"), Some(ApiVersion::new(1, 5))); assert_eq!(ApiVersion::parse("invalid"), None); } #[test] fn test_version_compatibility() { let v1_0 = ApiVersion::new(1, 0); let v1_1 = ApiVersion::new(1, 1); let v2_0 = ApiVersion::new(2, 0); assert!(v1_1.is_compatible_with(&v1_0)); assert!(!v1_0.is_compatible_with(&v1_1)); assert!(!v2_0.is_compatible_with(&v1_0)); } #[test] fn test_version_registry() { let registry = VersionRegistry::new(); assert!(registry.is_supported(&ApiVersion::new(1, 0))); assert!(registry.is_supported(&ApiVersion::new(2, 0))); assert!(!registry.is_supported(&ApiVersion::new(3, 0))); } #[test] fn test_version_display() { assert_eq!(ApiVersion::new(1, 0).to_string(), "1.0"); assert_eq!(ApiVersion::new(2, 5).to_string(), "2.5"); } }