use axum::{
extract::Request,
http::{header, HeaderValue, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
Router,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::fmt;
use tracing::warn;
#[cfg(feature = "otel-metrics")]
use opentelemetry::KeyValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ApiVersion {
V1,
V2,
V3,
V4,
V5,
}
impl ApiVersion {
pub fn parse(s: &str) -> Option<Self> {
let lowercase = s.to_lowercase();
let normalized = lowercase.trim_start_matches('v');
match normalized {
"1" => Some(Self::V1),
"2" => Some(Self::V2),
"3" => Some(Self::V3),
"4" => Some(Self::V4),
"5" => Some(Self::V5),
_ => None,
}
}
pub fn as_number(&self) -> u8 {
match self {
Self::V1 => 1,
Self::V2 => 2,
Self::V3 => 3,
Self::V4 => 4,
Self::V5 => 5,
}
}
pub fn as_path_segment(&self) -> &'static str {
match self {
Self::V1 => "v1",
Self::V2 => "v2",
Self::V3 => "v3",
Self::V4 => "v4",
Self::V5 => "v5",
}
}
pub fn is_deprecated(&self, latest: ApiVersion) -> bool {
*self < latest
}
}
impl fmt::Display for ApiVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_path_segment())
}
}
impl From<ApiVersion> for u8 {
fn from(version: ApiVersion) -> Self {
version.as_number()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeprecationInfo {
pub version: ApiVersion,
pub replacement: ApiVersion,
pub sunset_date: Option<String>,
pub message: Option<String>,
}
impl DeprecationInfo {
pub fn new(version: ApiVersion, replacement: ApiVersion) -> Self {
Self {
version,
replacement,
sunset_date: None,
message: None,
}
}
pub fn with_sunset_date(mut self, date: impl Into<String>) -> Self {
self.sunset_date = Some(date.into());
self
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
fn deprecation_header(&self) -> String {
format!("version=\"{}\"", self.version)
}
fn sunset_header(&self) -> Option<String> {
self.sunset_date.clone()
}
fn link_header(&self) -> String {
format!(
"</{}/>; rel=\"successor-version\"",
self.replacement.as_path_segment()
)
}
}
pub fn versioned_router(version: ApiVersion, router: Router) -> VersionedRouter {
VersionedRouter {
version,
router,
deprecation: None,
}
}
pub struct VersionedRouter {
version: ApiVersion,
router: Router,
deprecation: Option<DeprecationInfo>,
}
impl VersionedRouter {
pub fn deprecated(mut self, info: DeprecationInfo) -> Self {
self.deprecation = Some(info);
self
}
pub fn into_router(self) -> Router {
#[cfg(feature = "otel-metrics")]
let version = self.version;
let deprecation = self.deprecation.clone();
self.router.layer(middleware::from_fn(move |req: Request, next: Next| {
let deprecation = deprecation.clone();
#[cfg(feature = "otel-metrics")]
let version = version;
async move {
if let Some(ref deprecation_info) = deprecation {
let path = req.uri().path();
if let Some(sunset) = &deprecation_info.sunset_date {
warn!(
path = %path,
deprecated_version = %deprecation_info.version,
replacement_version = %deprecation_info.replacement,
sunset_date = %sunset,
message = deprecation_info.message.as_deref().unwrap_or(""),
"Deprecated API version accessed"
);
} else {
warn!(
path = %path,
deprecated_version = %deprecation_info.version,
replacement_version = %deprecation_info.replacement,
message = deprecation_info.message.as_deref().unwrap_or(""),
"Deprecated API version accessed"
);
}
}
#[cfg(feature = "otel-metrics")]
if let Some(meter) = crate::observability::get_meter() {
let counter = meter
.u64_counter("api.version.requests")
.with_description("Count of API requests by version")
.build();
let mut attributes = vec![
KeyValue::new("version", version.to_string()),
KeyValue::new("deprecated", deprecation.is_some().to_string()),
];
if let Some(ref deprecation_info) = deprecation {
attributes.push(KeyValue::new(
"replacement_version",
deprecation_info.replacement.to_string(),
));
}
counter.add(1, &attributes);
}
let mut response = next.run(req).await;
if let Some(ref deprecation_info) = deprecation {
let headers = response.headers_mut();
if let Ok(value) = HeaderValue::from_str(&deprecation_info.deprecation_header()) {
headers.insert("Deprecation", value);
}
if let Some(sunset) = deprecation_info.sunset_header() {
if let Ok(value) = HeaderValue::from_str(&sunset) {
headers.insert("Sunset", value);
}
}
if let Ok(value) = HeaderValue::from_str(&deprecation_info.link_header()) {
headers.insert(header::LINK, value);
}
if let Some(ref message) = deprecation_info.message {
let warning = format!(
"299 - \"API version {} is deprecated. Please migrate to version {}. {}\"",
deprecation_info.version, deprecation_info.replacement, message
);
if let Ok(value) = HeaderValue::from_str(&warning) {
headers.insert(header::WARNING, value);
}
}
}
response
}
}))
}
pub fn version(&self) -> ApiVersion {
self.version
}
pub fn is_deprecated(&self) -> bool {
self.deprecation.is_some()
}
}
pub fn extract_version_from_path(path: &str) -> Option<ApiVersion> {
path.split('/')
.find(|segment| segment.starts_with('v') || segment.starts_with('V'))
.and_then(ApiVersion::parse)
}
pub struct VersionedApiBuilder<T = ()>
where
T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
versions: Vec<(
ApiVersion,
Router<crate::state::AppState<T>>,
Option<DeprecationInfo>,
)>,
base_path: Option<String>,
#[cfg(feature = "htmx")]
frontend_routes: Option<Router<crate::state::AppState<T>>>,
}
impl Default for VersionedApiBuilder<()> {
fn default() -> Self {
Self::new()
}
}
impl VersionedApiBuilder<()> {
pub fn new() -> Self {
Self {
versions: Vec::new(),
base_path: None,
#[cfg(feature = "htmx")]
frontend_routes: None,
}
}
}
impl<T> VersionedApiBuilder<T>
where
T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
pub fn with_config() -> Self {
Self {
versions: Vec::new(),
base_path: None,
#[cfg(feature = "htmx")]
frontend_routes: None,
}
}
pub fn with_base_path(mut self, path: impl Into<String>) -> Self {
let path = path.into();
let normalized = if !path.starts_with('/') {
format!("/{}", path.trim_end_matches('/'))
} else {
path.trim_end_matches('/').to_string()
};
self.base_path = Some(normalized);
self
}
pub fn add_version<F>(mut self, version: ApiVersion, routes: F) -> Self
where
F: FnOnce(Router<crate::state::AppState<T>>) -> Router<crate::state::AppState<T>>,
{
let router = routes(Router::new());
self.versions.push((version, router, None));
self
}
pub fn add_version_deprecated<F>(
mut self,
version: ApiVersion,
routes: F,
deprecation: DeprecationInfo,
) -> Self
where
F: FnOnce(Router<crate::state::AppState<T>>) -> Router<crate::state::AppState<T>>,
{
let router = routes(Router::new());
self.versions.push((version, router, Some(deprecation)));
self
}
pub fn deprecate_version(mut self, version: ApiVersion, deprecation: DeprecationInfo) -> Self {
let entry = self
.versions
.iter_mut()
.find(|(v, _, _)| *v == version)
.expect("Version must be added before deprecating");
entry.2 = Some(deprecation);
self
}
#[cfg(feature = "htmx")]
pub fn with_frontend_routes<F>(mut self, routes: F) -> Self
where
F: FnOnce(Router<crate::state::AppState<T>>) -> Router<crate::state::AppState<T>>,
{
let router = routes(Router::new());
self.frontend_routes = Some(router);
self
}
pub fn build_routes(self) -> crate::service_builder::VersionedRoutes<T> {
use axum::routing::get;
let mut router: Router<crate::state::AppState<T>> = Router::new()
.route("/health", get(crate::health::health::<T>))
.route("/ready", get(crate::health::readiness::<T>));
#[cfg(feature = "htmx")]
if let Some(frontend_router) = self.frontend_routes {
router = router.merge(frontend_router);
}
for (version, version_router, deprecation) in self.versions {
let version_path = format!("/{}", version.as_path_segment());
let full_path = if let Some(ref base) = self.base_path {
format!("{}{}", base, version_path)
} else {
version_path
};
let versioned = if let Some(deprecation) = deprecation {
version_router.layer(middleware::from_fn(move |req: Request, next: Next| {
let deprecation = deprecation.clone();
async move {
let path = req.uri().path().to_string();
if let Some(sunset) = &deprecation.sunset_date {
warn!(
path = %path,
deprecated_version = %deprecation.version,
replacement_version = %deprecation.replacement,
sunset_date = %sunset,
message = deprecation.message.as_deref().unwrap_or(""),
"Deprecated API version accessed"
);
} else {
warn!(
path = %path,
deprecated_version = %deprecation.version,
replacement_version = %deprecation.replacement,
message = deprecation.message.as_deref().unwrap_or(""),
"Deprecated API version accessed"
);
}
let mut response = next.run(req).await;
let headers = response.headers_mut();
if let Ok(value) = HeaderValue::from_str(&deprecation.deprecation_header()) {
headers.insert("Deprecation", value);
}
if let Some(sunset) = deprecation.sunset_header() {
if let Ok(value) = HeaderValue::from_str(&sunset) {
headers.insert("Sunset", value);
}
}
if let Ok(value) = HeaderValue::from_str(&deprecation.link_header()) {
headers.insert(header::LINK, value);
}
if let Some(ref message) = deprecation.message {
let warning = format!(
"299 - \"API version {} is deprecated. Please migrate to version {}. {}\"",
deprecation.version, deprecation.replacement, message
);
if let Ok(value) = HeaderValue::from_str(&warning) {
headers.insert(header::WARNING, value);
}
}
response
}
}))
} else {
version_router
};
router = router.nest(&full_path, versioned);
}
crate::service_builder::VersionedRoutes::from_router_with_state(router)
}
pub fn version_count(&self) -> usize {
self.versions.len()
}
pub fn has_version(&self, version: ApiVersion) -> bool {
self.versions.iter().any(|(v, _, _)| *v == version)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VersionedResponse<T> {
pub version: ApiVersion,
pub data: T,
}
impl<T> VersionedResponse<T> {
pub fn new(version: ApiVersion, data: T) -> Self {
Self { version, data }
}
}
impl<T: Serialize> IntoResponse for VersionedResponse<T> {
fn into_response(self) -> Response {
match serde_json::to_vec(&self) {
Ok(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
body,
)
.into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize response: {}", err),
)
.into_response(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_parsing() {
assert_eq!(ApiVersion::parse("v1"), Some(ApiVersion::V1));
assert_eq!(ApiVersion::parse("V1"), Some(ApiVersion::V1));
assert_eq!(ApiVersion::parse("1"), Some(ApiVersion::V1));
assert_eq!(ApiVersion::parse("v2"), Some(ApiVersion::V2));
assert_eq!(ApiVersion::parse("3"), Some(ApiVersion::V3));
assert_eq!(ApiVersion::parse("v99"), None);
}
#[test]
fn test_version_comparison() {
assert!(ApiVersion::V1 < ApiVersion::V2);
assert!(ApiVersion::V2 > ApiVersion::V1);
assert_eq!(ApiVersion::V1, ApiVersion::V1);
}
#[test]
fn test_version_as_number() {
assert_eq!(ApiVersion::V1.as_number(), 1);
assert_eq!(ApiVersion::V2.as_number(), 2);
assert_eq!(ApiVersion::V5.as_number(), 5);
}
#[test]
fn test_version_deprecation() {
assert!(ApiVersion::V1.is_deprecated(ApiVersion::V2));
assert!(!ApiVersion::V2.is_deprecated(ApiVersion::V2));
assert!(!ApiVersion::V3.is_deprecated(ApiVersion::V2));
}
#[test]
fn test_extract_version_from_path() {
assert_eq!(extract_version_from_path("/v1/users"), Some(ApiVersion::V1));
assert_eq!(
extract_version_from_path("/api/v2/users/123"),
Some(ApiVersion::V2)
);
assert_eq!(extract_version_from_path("/users"), None);
}
#[test]
fn test_deprecation_info() {
let info = DeprecationInfo::new(ApiVersion::V1, ApiVersion::V2)
.with_sunset_date("2026-12-31T23:59:59Z")
.with_message("Please migrate soon");
assert_eq!(info.version, ApiVersion::V1);
assert_eq!(info.replacement, ApiVersion::V2);
assert_eq!(info.sunset_date, Some("2026-12-31T23:59:59Z".to_string()));
assert_eq!(info.message, Some("Please migrate soon".to_string()));
}
#[test]
fn test_deprecation_headers() {
let info = DeprecationInfo::new(ApiVersion::V1, ApiVersion::V2)
.with_sunset_date("2026-12-31T23:59:59Z");
assert_eq!(info.deprecation_header(), "version=\"v1\"");
assert_eq!(
info.sunset_header(),
Some("2026-12-31T23:59:59Z".to_string())
);
assert_eq!(info.link_header(), "</v2/>; rel=\"successor-version\"");
}
#[test]
fn test_versioned_api_builder_basic() {
let builder = VersionedApiBuilder::new()
.add_version(ApiVersion::V1, |routes| {
routes.route("/users", axum::routing::get(|| async { "V1" }))
})
.add_version(ApiVersion::V2, |routes| {
routes.route("/users", axum::routing::get(|| async { "V2" }))
});
assert_eq!(builder.version_count(), 2);
assert!(builder.has_version(ApiVersion::V1));
assert!(builder.has_version(ApiVersion::V2));
assert!(!builder.has_version(ApiVersion::V3));
}
#[test]
fn test_versioned_api_builder_with_base_path() {
let builder = VersionedApiBuilder::new()
.with_base_path("/api")
.add_version(ApiVersion::V1, |routes| {
routes.route("/users", axum::routing::get(|| async { "V1" }))
});
assert_eq!(builder.version_count(), 1);
assert!(builder.has_version(ApiVersion::V1));
}
#[test]
fn test_versioned_api_builder_with_deprecation() {
let deprecation = DeprecationInfo::new(ApiVersion::V1, ApiVersion::V2)
.with_sunset_date("2026-12-31T23:59:59Z");
let builder = VersionedApiBuilder::new()
.add_version_deprecated(
ApiVersion::V1,
|routes| routes.route("/users", axum::routing::get(|| async { "V1" })),
deprecation,
)
.add_version(ApiVersion::V2, |routes| {
routes.route("/users", axum::routing::get(|| async { "V2" }))
});
assert_eq!(builder.version_count(), 2);
}
#[test]
fn test_versioned_api_builder_deprecate_existing() {
let builder = VersionedApiBuilder::new()
.add_version(ApiVersion::V1, |routes| {
routes.route("/users", axum::routing::get(|| async { "V1" }))
})
.deprecate_version(
ApiVersion::V1,
DeprecationInfo::new(ApiVersion::V1, ApiVersion::V2),
);
assert_eq!(builder.version_count(), 1);
}
#[test]
#[should_panic(expected = "Version must be added before deprecating")]
fn test_versioned_api_builder_deprecate_nonexistent() {
let _ = VersionedApiBuilder::new().deprecate_version(
ApiVersion::V1,
DeprecationInfo::new(ApiVersion::V1, ApiVersion::V2),
);
}
#[test]
#[cfg(feature = "htmx")]
fn test_versioned_api_builder_with_frontend_routes() {
let _routes = VersionedApiBuilder::new()
.with_base_path("/api")
.add_version(ApiVersion::V1, |routes| {
routes.route("/data", axum::routing::get(|| async { "API V1" }))
})
.with_frontend_routes(|router| {
router
.route("/", axum::routing::get(|| async { "Home" }))
.route("/login", axum::routing::get(|| async { "Login" }))
})
.build_routes();
}
#[test]
#[cfg(feature = "htmx")]
fn test_versioned_api_builder_frontend_routes_only() {
let _routes = VersionedApiBuilder::new()
.with_frontend_routes(|router| {
router
.route("/", axum::routing::get(|| async { "Home" }))
.route("/about", axum::routing::get(|| async { "About" }))
})
.build_routes();
}
}