Navigating API Versioning in Rust with Axum and Actix Web
Lukas Schneider
DevOps Engineer · Leapcell

Introduction
As web applications evolve, their APIs inevitably change. New features are added, old ones are deprecated, and data structures are refined. To ensure that these changes don't break existing client applications, robust API versioning becomes a crucial practice. Without a clear strategy, API updates can lead to significant compatibility issues, frustrated users, and a heavy maintenance burden. In the Rust web ecosystem, frameworks like Axum and Actix Web offer powerful tools for building performant and reliable APIs. This article delves into two primary API versioning strategies – using URL paths and leveraging the Accept
header – examining their implementation details, advantages, and disadvantages within the context of these popular Rust frameworks.
Understanding API Versioning Strategies
Before diving into the specifics, let's define the core concepts related to API versioning. API versioning is the practice of maintaining multiple versions of an API concurrently, allowing clients to choose which version they interact with. This prevents breaking changes from affecting older clients while enabling new clients to benefit from the latest features.
The two main strategies we'll explore are:
- URL Path Versioning: This involves embedding the API version directly into the URL path. For example,
/api/v1/users
and/api/v2/users
. - Accept Header Versioning: Here, clients specify their desired API version by sending a custom media type within the
Accept
HTTP header. For instance,Accept: application/vnd.myapi.v1+json
.
URL Path Versioning
Principle and Implementation
URL path versioning is arguably the most straightforward and widely adopted method. The version number is a clear, visible part of the endpoint's path. This makes it intuitive for developers to understand which version they are interacting with and allows for simple routing rules.
In Rust, both Axum and Actix Web make implementing URL path versioning quite natural through their routing mechanisms.
Axum Example
Axum's routing is based on services, and defining routes with version prefixes is very clean.
use axum::{ routing::get, Router, response::IntoResponse, }; async fn get_users_v1() -> impl IntoResponse { "Getting users from API V1!" } async fn get_users_v2() -> impl IntoResponse { "Getting users from API V2!" } #[tokio::main] async fn main() { let app = Router::new() .nest("/api/v1", Router::new().route("/users", get(get_users_v1))) .nest("/api/v2", Router::new().route("/users", get(get_users_v2))); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
In this Axum example, we create two nested Router
instances, one for /api/v1
and another for /api/v2
. Each handles the /users
endpoint independently, directing requests to their respective version-specific handlers.
Actix Web Example
Actix Web uses macros and function attributes for routing, which also lends itself well to path-based versioning.
use actix_web::{get, web, App, HttpServer, Responder}; #[get("/api/v1/users")] async fn get_users_v1_actix() -> impl Responder { "Getting users from API V1!" } #[get("/api/v2/users")] async fn get_users_v2_actix() -> impl Responder { "Getting users from API V2!" } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service(get_users_v1_actix) .service(get_users_v2_actix) }) .bind(("127.0.0.1", 8080))? .run() .await }
Here, Actix Web's #[get]
macro directly defines the full path including the version, making it very explicit. Each version's handler is registered as a separate service.
Advantages of URL Path Versioning
- Discoverability: The version is immediately obvious in the URL, making it easy for developers and documentation.
- Caching: Proxies and caches can treat different versions as entirely separate resources, simplifying caching strategies.
- Browser-Friendly: You can directly access different API versions via a web browser.
- Simplicity: Routing is generally straightforward to implement and understand.
Disadvantages of URL Path Versioning
- URL Pollution: URLs can become longer and less elegant with version numbers.
- Client-Side "Stickiness": Clients hardcoding version numbers in URLs might need to be updated en masse when a new version becomes the default or an old one is deprecated.
- Routing Overhead: Potentially more routes to manage if many endpoints are versioned.
Accept Header Versioning
Principle and Implementation
Accept header versioning (also known as content negotiation) relies on the Accept
HTTP header. Clients specify a custom media type that includes the API version, and the server responds accordingly. This keeps the URL clean and allows the same URL path to serve multiple versions of a resource.
The custom media type typically follows a convention like application/vnd.company.resource.vX+json
.
Implementing this strategy requires inspecting the Accept
header and routing requests based on its content.
Axum Example
Axum's extractors are excellent for implementing header-based versioning. We can create a custom extractor that parses the Accept
header.
use axum::{ routing::get, Router, async_trait, extract::{FromRequestParts, Request, State}, http::{header, request::Parts}, response::{IntoResponse, Response}, }; use std::collections::HashMap; // Custom extractor for API version pub struct ApiVersion(pub u8); #[async_trait] impl<S> FromRequestParts<S> for ApiVersion where S: Send + Sync, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { let accept_header = parts.headers .get(header::ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or_default(); // Example: Parse "application/vnd.myapi.v1+json" if let Some(version_str) = accept_header.find("vnd.myapi.v") { let start = version_str + "vnd.myapi.v".len(); if let Some(end) = accept_header[start..].find('+') { if let Ok(version) = accept_header[start..start + end].parse::<u8>() { return Ok(ApiVersion(version)); } } } // Default to version 1 if no specific version or unsupported version is requested Ok(ApiVersion(1)) } } async fn get_users_versioned(ApiVersion(version): ApiVersion) -> impl IntoResponse { match version { 1 => "Getting users from API V1 via Accept header!".to_string(), 2 => "Getting users from API V2 via Accept header!".to_string(), _ => format!("Unsupported API version: {}", version), } } #[tokio::main] async fn main() { let app = Router::new() .route("/api/users", get(get_users_versioned)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
In Axum, we define ApiVersion
as a custom extractor that processes the Accept
header. The from_request_parts
implementation attempts to parse the version number from the header. The get_users_versioned
handler then consumes this ApiVersion
to decide which logic to execute.
Actix Web Example
Actix Web also allows for custom extractors and has robust support for header inspection.
use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse, Responder, HttpMessage}; use actix_web::http::header::{ACCEPT, CONTENT_TYPE}; // Custom extractor for API version in Actix Web struct ApiVersionActix(u8); impl actix_web::FromRequest for ApiVersionActix { type Error = actix_web::Error; type Future = std::future::Ready<Result<Self, Self::Error>>; fn from_request(req: &HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { let accept_header = req.headers() .get(ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or_default(); let mut version = 1; // Default to V1 if let Some(version_str_idx) = accept_header.find("vnd.myapi.v") { let start = version_str_idx + "vnd.myapi.v".len(); if let Some(end_idx) = accept_header[start..].find('+') { if let Ok(parsed_version) = accept_header[start..start + end_idx].parse::<u8>() { version = parsed_version; } } } std::future::ready(Ok(ApiVersionActix(version))) } } async fn get_users_versioned_actix(api_version: ApiVersionActix) -> impl Responder { match api_version.0 { 1 => HttpResponse::Ok().body("Getting users from API V1 via Accept header!"), 2 => HttpResponse::Ok().body("Getting users from API V2 via Accept header!"), _ => HttpResponse::BadRequest().body(format!("Unsupported API version: {}", api_version.0)), } } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service( web::resource("/api/users") .route(web::get().to(get_users_versioned_actix)) ) }) .bind(("127.0.0.1", 8080))? .run() .await }
The Actix Web ApiVersionActix
also implements FromRequest
, allowing it to be used as an argument in handler functions. The logic for parsing the Accept
header is similar to the Axum example.
Advantages of Accept Header Versioning
- Clean URLs: URLs remain stable across different API versions, improving aesthetics and potentially SEO for public APIs.
- Single Endpoint for Multiple Versions: The same path
/api/users
can serve different representations of users based on the client's requested version. - Flexibility: Allows clients to easily request specific versions without changing the entire URL structure.
Disadvantages of Accept Header Versioning
- Less Discoverable: The version is hidden within a header, making it less obvious which version is being accessed without inspecting network requests.
- Caching Complexity: Caching proxies might struggle to cache responses effectively, as the cache key needs to include the
Accept
header, potentially leading to cache misses. - Browser Incompatibility: Directly accessing versions via a web browser is difficult as browsers don't typically allow customizing the
Accept
header directly. - Error Handling: It can be harder to communicate to a client that they are requesting an unsupported version or that their
Accept
header is malformed.
Choosing the Right Strategy
The choice between URL path and Accept header versioning often boils down to a few key considerations:
- API Audience: For public-facing APIs where discoverability and direct browser access are important, URL path versioning might be preferred. For internal or machine-to-machine APIs, Accept header versioning offers cleaner URLs.
- Caching Needs: If caching is critical and implemented with external proxies, URL path versioning simplifies caching strategies.
- Client-Side Flexibility: If clients frequently need to swap between versions for the same resource, Accept header versioning can be more adaptable.
- Architectural Simplicity: URL path versioning is generally easier to reason about and implement initially for most developers.
It's also worth noting that these strategies are not mutually exclusive. Some APIs might use a hybrid approach, or even date-based versioning for minor changes. However, for most common use cases, one of these two will be sufficient.
Conclusion
API versioning is an indispensable aspect of designing scalable and maintainable web services. In the Rust ecosystem, Axum and Actix Web provide the necessary tools and flexibility to implement both URL path and Accept header versioning effectively. While URL path versioning offers simplicity and discoverability, Accept header versioning delivers cleaner URLs and greater flexibility in resource representation. Thoughtfully choosing the right strategy for your project will greatly enhance the longevity and usability of your APIs.