- 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
498 lines
16 KiB
Rust
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(®istry))
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|