versioning - path parameter with fixed values
Sometimes the path-parameter is from a fixed set of values. For example if we are build an API and the first part of the path is the version number of the API then we might accept the strings v1, v2, v3, but no other value.
In this example we see exactly that.
[package]
name = "versioning"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8.8"
mime = "0.3.17"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.50.0", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
[dev-dependencies]
headers = "0.4.1"
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
Code
use axum::{
RequestPartsExt, Router,
extract::{FromRequestParts, Path},
http::{StatusCode, request::Parts},
response::{Html, IntoResponse, Response},
routing::get,
};
use std::collections::HashMap;
async fn main_page() -> Html<&'static str> {
Html(
r#"
<a href="/api/v1/status">v1</a><br>
<a href="/api/v2/status">v2</a><br>
<a href="/api/v3/status">v3</a><br>
<a href="/api/v4/status">v4</a><br>
"#,
)
}
#[derive(Debug)]
enum Version {
V1,
V2,
V3,
}
impl<S> FromRequestParts<S> for Version
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let params: Path<HashMap<String, String>> =
parts.extract().await.map_err(IntoResponse::into_response)?;
let version = params
.get("version")
.ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?;
match version.as_str() {
"v1" => Ok(Version::V1),
"v2" => Ok(Version::V2),
"v3" => Ok(Version::V3),
_ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()),
}
}
}
async fn handle_api(version: Version) -> Html<String> {
Html(format!("received request with version {version:?}"))
}
fn create_router() -> Router {
Router::new()
.route("/", get(main_page))
.route("/api/{version}/status", get(handle_api))
}
#[tokio::main]
async fn main() {
let app = create_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[cfg(test)]
mod tests;
Tests
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request, http::StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn test_v1() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v1/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "received request with version V1");
}
#[tokio::test]
async fn test_v4() {
let response = create_router()
.oneshot(
Request::builder()
.uri("/api/v4/status")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(html, "unknown version");
}
}