use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ApiVersion(pub String);
impl ApiVersion {
#[inline]
pub fn new(label: impl Into<String>) -> Self {
Self(label.into())
}
}
#[derive(Clone, Debug)]
pub struct VersionConfig {
pub default: ApiVersion,
pub supported: Arc<[ApiVersion]>,
}
impl VersionConfig {
pub fn new(default: ApiVersion, supported: Vec<ApiVersion>) -> Self {
let mut supported = supported;
if !supported.iter().any(|v| v == &default) {
supported.push(default.clone());
}
Self {
default,
supported: supported.into(),
}
}
fn resolve(&self, headers: &HeaderMap, path: &str) -> Option<ApiVersion> {
if let Some(v) = extract_header_version(headers) {
if self.supported.iter().any(|s| s == &v) {
return Some(v);
}
return None;
}
if let Some(v) = extract_path_version(path) {
if self.supported.iter().any(|s| s == &v) {
return Some(v);
}
return None;
}
Some(self.default.clone())
}
}
pub const ACCEPT_VERSION: &str = "accept-version";
pub const API_VERSION_HEADER: &str = "api-version";
pub fn extract_header_version(headers: &HeaderMap) -> Option<ApiVersion> {
headers
.get(ACCEPT_VERSION)
.and_then(|v| v.to_str().ok())
.map(|s| ApiVersion(s.trim().to_string()))
.filter(|v| !v.0.is_empty())
}
pub fn extract_path_version(path: &str) -> Option<ApiVersion> {
let rest = path.strip_prefix('/')?;
let segment = rest.split('/').next()?;
if segment.starts_with('v')
&& segment.len() > 1
&& segment[1..].chars().all(|c| c.is_ascii_digit())
{
Some(ApiVersion(segment.to_string()))
} else {
None
}
}
pub fn set_response_version(headers: &mut HeaderMap, version: &ApiVersion) {
if let Ok(value) = version.0.parse() {
headers.insert(API_VERSION_HEADER, value);
}
}
pub async fn negotiate_api_version(
axum::extract::State(config): axum::extract::State<VersionConfig>,
mut request: Request,
next: Next,
) -> Response {
let path = request.uri().path().to_string();
match config.resolve(request.headers(), &path) {
Some(version) => {
request.extensions_mut().insert(version);
next.run(request).await
}
None => (
StatusCode::NOT_ACCEPTABLE,
format!("unsupported API version; supported: {:?}", config.supported),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::HeaderMap;
use axum::routing::get;
use tower::ServiceExt;
#[test]
fn extract_path_version_parses_v_prefix() {
assert_eq!(
extract_path_version("/v2/users"),
Some(ApiVersion("v2".into()))
);
assert_eq!(extract_path_version("/health"), None);
}
#[test]
fn extract_header_version_reads_accept_version() {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT_VERSION, "v2".parse().unwrap());
assert_eq!(
extract_header_version(&headers),
Some(ApiVersion("v2".into()))
);
}
#[test]
fn version_config_includes_default_in_supported() {
let cfg = VersionConfig::new(ApiVersion("v1".into()), vec![ApiVersion("v2".into())]);
assert!(cfg.supported.iter().any(|v| v.0 == "v1"));
assert!(cfg.supported.iter().any(|v| v.0 == "v2"));
}
#[test]
fn set_response_version_inserts_header() {
let mut headers = HeaderMap::new();
set_response_version(&mut headers, &ApiVersion("v3".into()));
assert_eq!(headers.get(API_VERSION_HEADER).unwrap(), "v3");
}
#[tokio::test]
async fn middleware_returns_406_for_unsupported_header_version() {
let config = VersionConfig::new(ApiVersion("v1".into()), vec![ApiVersion("v1".into())]);
let app =
Router::new()
.route("/", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
config,
negotiate_api_version,
));
let res = app
.oneshot(
Request::builder()
.uri("/")
.header(ACCEPT_VERSION, "v9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn middleware_uses_path_version_when_present() {
let config = VersionConfig::new(
ApiVersion("v1".into()),
vec![ApiVersion("v1".into()), ApiVersion("v2".into())],
);
let app = Router::new()
.route(
"/v2/hello",
get(|ext: axum::Extension<ApiVersion>| async move { ext.0.0.clone() }),
)
.layer(axum::middleware::from_fn_with_state(
config,
negotiate_api_version,
));
let res = app
.oneshot(
Request::builder()
.uri("/v2/hello")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn middleware_defaults_when_unspecified() {
let config = VersionConfig::new(ApiVersion("v1".into()), vec![ApiVersion("v1".into())]);
let app = Router::new()
.route(
"/",
get(|ext: axum::Extension<ApiVersion>| async move { ext.0.0.clone() }),
)
.layer(axum::middleware::from_fn_with_state(
config,
negotiate_api_version,
));
let res = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
}