use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ApiVersion(pub u32);
impl ApiVersion {
pub fn from_path(path: &str) -> Option<Self> {
let path = path.strip_prefix('/')?;
let seg = path.split('/').next()?;
let ver_str = seg.strip_prefix('v').or_else(|| seg.strip_prefix('V'))?;
ver_str.parse::<u32>().ok().map(ApiVersion)
}
pub fn from_header_value(value: &str) -> Option<Self> {
let trimmed = value.trim();
let num_str = trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
.unwrap_or(trimmed);
num_str.parse::<u32>().ok().map(ApiVersion)
}
pub fn from_accept_header(accept: &str) -> Option<Self> {
for part in accept.split(';') {
let part = part.trim();
for segment in part.split('.') {
if let Some(num_str) = segment
.strip_prefix('v')
.or_else(|| segment.strip_prefix('V'))
{
let digits: String = num_str.chars().take_while(char::is_ascii_digit).collect();
if let Ok(n) = digits.parse::<u32>() {
return Some(ApiVersion(n));
}
}
}
}
None
}
pub fn strip_prefix(path: &str) -> &str {
let Some(rest) = path.strip_prefix('/') else {
return path;
};
let Some(after_seg) = rest.find('/') else {
return path;
};
let seg = &rest[..after_seg];
if seg.starts_with('v') || seg.starts_with('V') {
let num_part = &seg[1..];
if num_part.chars().all(|c| c.is_ascii_digit()) && !num_part.is_empty() {
return &rest[after_seg..];
}
}
path
}
}
impl fmt::Display for ApiVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionStrategy {
UrlPrefix,
Header,
AcceptHeader,
}
#[derive(Debug, Clone)]
pub struct VersionConfig {
pub strategy: VersionStrategy,
pub current_version: u32,
pub supported_versions: Vec<u32>,
pub deprecated_versions: Vec<u32>,
pub version_header: String,
pub deprecation_header: String,
}
impl Default for VersionConfig {
fn default() -> Self {
Self {
strategy: VersionStrategy::UrlPrefix,
current_version: 1,
supported_versions: vec![1],
deprecated_versions: Vec::new(),
version_header: "X-API-Version".to_string(),
deprecation_header: "Deprecation".to_string(),
}
}
}
impl VersionConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn strategy(mut self, strategy: VersionStrategy) -> Self {
self.strategy = strategy;
self
}
#[must_use]
pub fn current(mut self, version: u32) -> Self {
self.current_version = version;
self
}
#[must_use]
pub fn supported(mut self, versions: &[u32]) -> Self {
self.supported_versions = versions.to_vec();
self
}
#[must_use]
pub fn deprecated(mut self, versions: &[u32]) -> Self {
self.deprecated_versions = versions.to_vec();
self
}
#[must_use]
pub fn version_header(mut self, name: impl Into<String>) -> Self {
self.version_header = name.into();
self
}
pub fn is_supported(&self, version: &ApiVersion) -> bool {
self.supported_versions.contains(&version.0)
}
pub fn is_deprecated(&self, version: &ApiVersion) -> bool {
self.deprecated_versions.contains(&version.0)
}
pub fn extract_from_path(&self, path: &str) -> Option<ApiVersion> {
ApiVersion::from_path(path)
}
pub fn extract_from_header(&self, value: &str) -> Option<ApiVersion> {
ApiVersion::from_header_value(value)
}
pub fn deprecation_warning(&self, version: &ApiVersion) -> Option<String> {
if self.is_deprecated(version) {
Some(format!(
"API version {} is deprecated. Please migrate to v{}.",
version, self.current_version
))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_version_from_path() {
assert_eq!(ApiVersion::from_path("/v1/users"), Some(ApiVersion(1)));
assert_eq!(ApiVersion::from_path("/v2/items/5"), Some(ApiVersion(2)));
assert_eq!(ApiVersion::from_path("/v10/"), Some(ApiVersion(10)));
assert_eq!(ApiVersion::from_path("/users"), None);
assert_eq!(ApiVersion::from_path("/"), None);
assert_eq!(ApiVersion::from_path(""), None);
}
#[test]
fn api_version_from_header() {
assert_eq!(ApiVersion::from_header_value("1"), Some(ApiVersion(1)));
assert_eq!(ApiVersion::from_header_value("v2"), Some(ApiVersion(2)));
assert_eq!(ApiVersion::from_header_value(" 3 "), Some(ApiVersion(3)));
assert_eq!(ApiVersion::from_header_value("abc"), None);
}
#[test]
fn api_version_from_accept() {
assert_eq!(
ApiVersion::from_accept_header("application/vnd.myapi.v1+json"),
Some(ApiVersion(1))
);
assert_eq!(
ApiVersion::from_accept_header("application/vnd.api.v3+json; charset=utf-8"),
Some(ApiVersion(3))
);
assert_eq!(ApiVersion::from_accept_header("application/json"), None);
}
#[test]
fn strip_version_prefix() {
assert_eq!(ApiVersion::strip_prefix("/v1/users"), "/users");
assert_eq!(ApiVersion::strip_prefix("/v2/items/5"), "/items/5");
assert_eq!(ApiVersion::strip_prefix("/users"), "/users");
assert_eq!(ApiVersion::strip_prefix("/"), "/");
}
#[test]
fn version_display() {
assert_eq!(ApiVersion(1).to_string(), "v1");
assert_eq!(ApiVersion(42).to_string(), "v42");
}
#[test]
fn version_config_builder() {
let config = VersionConfig::new()
.strategy(VersionStrategy::Header)
.current(3)
.supported(&[1, 2, 3])
.deprecated(&[1]);
assert_eq!(config.strategy, VersionStrategy::Header);
assert_eq!(config.current_version, 3);
assert!(config.is_supported(&ApiVersion(2)));
assert!(!config.is_supported(&ApiVersion(4)));
assert!(config.is_deprecated(&ApiVersion(1)));
assert!(!config.is_deprecated(&ApiVersion(2)));
}
#[test]
fn deprecation_warning() {
let config = VersionConfig::new()
.current(2)
.supported(&[1, 2])
.deprecated(&[1]);
let warning = config.deprecation_warning(&ApiVersion(1));
assert!(warning.is_some());
assert!(warning.unwrap().contains("v2"));
assert!(config.deprecation_warning(&ApiVersion(2)).is_none());
}
#[test]
fn version_config_defaults() {
let config = VersionConfig::default();
assert_eq!(config.strategy, VersionStrategy::UrlPrefix);
assert_eq!(config.current_version, 1);
assert_eq!(config.version_header, "X-API-Version");
}
#[test]
fn version_ordering() {
assert!(ApiVersion(1) < ApiVersion(2));
assert!(ApiVersion(3) > ApiVersion(1));
assert_eq!(ApiVersion(1), ApiVersion(1));
}
}