synor/crates/synor-gateway/src/versioning.rs
Gulshan Yadav 97f42cb990 feat: add CLI commands, WebSocket channels, and API versioning
- Add CLI commands for DEX, IBC, ZK, and Compiler services
  - DEX: markets, orderbook, orders, liquidity pools
  - IBC: chains, transfers, packets, relayers
  - ZK: circuit compilation, proof generation (Groth16/PLONK/STARK)
  - Compiler: WASM compilation, ABI extraction, security scan

- Add WebSocket module for real-time event streaming
  - Block, transaction, address, contract event channels
  - Market and mining event streams
  - Subscription management with broadcast channels

- Implement API versioning strategy
  - URL path, header, and query parameter versioning
  - Version registry with deprecation support
  - Deprecation and sunset headers
2026-01-28 15:31:57 +05:30

498 lines
16 KiB
Rust

//! 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<Self> {
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<DateTime<Utc>>,
/// Sunset date (when version will be removed)
pub sunset_at: Option<DateTime<Utc>>,
/// Release notes URL
pub release_notes_url: Option<String>,
/// Changes from previous version
pub changes: Vec<String>,
}
/// 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::<Vec<_>>()
.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<VersionDetail>,
/// Deprecated versions
pub deprecated: Vec<VersionDetail>,
}
/// 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<String>,
/// When it will be removed (if applicable)
#[serde(skip_serializing_if = "Option::is_none")]
pub sunset_at: Option<String>,
/// URL to release notes
#[serde(skip_serializing_if = "Option::is_none")]
pub release_notes: Option<String>,
}
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<AppState> {
Router::new()
.route("/versions", get(get_versions))
.route("/version", get(get_current_version))
}
/// Get all API versions.
async fn get_versions() -> Json<VersionsResponse> {
let registry = VersionRegistry::new();
Json(VersionsResponse::from_registry(&registry))
}
/// Get current API version.
async fn get_current_version() -> Json<serde_json::Value> {
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");
}
}