Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

API Versioning

API versioning allows you to manage changes to your API over time while maintaining backward compatibility. This chapter covers various approaches to API versioning in Oxidite.

Overview

API versioning strategies include:

  • URL-based versioning (e.g., /api/v1/users)
  • Header-based versioning (e.g., Accept: application/vnd.api.v1+json)
  • Query parameter versioning (e.g., ?version=1)
  • Media type versioning
  • Semantic versioning practices

URL-Based Versioning

The most common approach is to include the version in the URL path:

use oxidite::prelude::*;

// V1 API routes
async fn v1_get_users(_req: Request) -> Result<Response> {
    Ok(Response::json(serde_json::json!([
        {"id": 1, "name": "John", "email": "john@example.com"}
    ])))
}

async fn v1_get_user(Path(user_id): Path<u32>) -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "id": user_id,
        "name": format!("User {}", user_id),
        "email": format!("user{}@example.com", user_id)
    })))
}

// V2 API routes - with breaking changes
async fn v2_get_users(_req: Request) -> Result<Response> {
    Ok(Response::json(serde_json::json!([
        {
            "id": 1,
            "name": "John",
            "email": "john@example.com",
            "profile": {
                "bio": "Software developer",
                "avatar_url": "https://example.com/avatar.jpg"
            }
        }
    ])))
}

async fn v2_get_user(Path(user_id): Path<u32>) -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "id": user_id,
        "name": format!("User {}", user_id),
        "email": format!("user{}@example.com", user_id),
        "profile": {
            "bio": "User bio",
            "avatar_url": format!("https://example.com/avatar/{}.jpg", user_id),
            "preferences": {
                "theme": "light",
                "notifications": true
            }
        }
    })))
}

#[tokio::main]
async fn main() -> Result<()> {
    let mut router = Router::new();
    
    // V1 API
    router.get("/api/v1/users", v1_get_users);
    router.get("/api/v1/users/:id", v1_get_user);
    
    // V2 API
    router.get("/api/v2/users", v2_get_users);
    router.get("/api/v2/users/:id", v2_get_user);
    
    Server::new(router)
        .listen("127.0.0.1:3000".parse()?)
        .await
}

Header-Based Versioning

Use HTTP headers to specify the API version:

use oxidite::prelude::*;

// Middleware to extract version from headers
async fn version_middleware(req: Request, next: Next) -> Result<Response> {
    // Extract version from Accept header
    let version = req.headers()
        .get("accept")
        .and_then(|hv| hv.to_str().ok())
        .and_then(|accept| {
            // Look for version in vendor media type
            // e.g., application/vnd.myapi.v1+json
            if accept.contains("application/vnd.myapi.v") {
                accept.find("v").and_then(|pos| {
                    accept[pos + 1..].chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse::<u32>().ok()
                })
            } else {
                None
            }
        })
        .or_else(|| {
            // Fallback to custom header
            req.headers()
                .get("x-api-version")
                .and_then(|hv| hv.to_str().ok())
                .and_then(|version_str| version_str.parse::<u32>().ok())
        })
        .unwrap_or(1); // Default to v1
    
    // Add version to request extensions
    let mut req = req;
    req.extensions_mut().insert(ApiVersion(version));
    
    next.run(req).await
}

#[derive(Clone)]
struct ApiVersion(u32);

// Route handlers that check the version
async fn get_users_by_version(req: Request) -> Result<Response> {
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        match version {
            1 => v1_get_users(req).await,
            2 => v2_get_users(req).await,
            _ => Err(Error::NotImplemented),
        }
    } else {
        v1_get_users(req).await // Default to v1
    }
}

async fn v1_get_users(_req: Request) -> Result<Response> {
    Ok(Response::json(serde_json::json!([
        {"id": 1, "name": "John", "email": "john@example.com"}
    ])))
}

async fn v2_get_users(_req: Request) -> Result<Response> {
    Ok(Response::json(serde_json::json!([
        {
            "id": 1,
            "name": "John",
            "email": "john@example.com",
            "profile": {
                "bio": "Software developer",
                "avatar_url": "https://example.com/avatar.jpg"
            }
        }
    ])))
}

Query Parameter Versioning

Use query parameters to specify the API version:

use oxidite::prelude::*;
use serde::Deserialize;

#[derive(Deserialize)]
struct VersionedQuery {
    version: Option<u32>,
}

async fn versioned_handler(Query(params): Query<VersionedQuery>) -> Result<Response> {
    let version = params.version.unwrap_or(1);
    
    match version {
        1 => v1_response(),
        2 => v2_response(),
        _ => Err(Error::NotImplemented),
    }
}

fn v1_response() -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "data": [
            {"id": 1, "name": "John"}
        ],
        "version": "v1"
    })))
}

fn v2_response() -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "data": [
            {
                "id": 1,
                "name": "John",
                "metadata": {
                    "created_at": "2023-01-01T00:00:00Z",
                    "updated_at": "2023-01-02T00:00:00Z"
                }
            }
        ],
        "version": "v2",
        "pagination": {
            "page": 1,
            "per_page": 10,
            "total": 100
        }
    })))
}

// Alternative: Middleware approach for query versioning
async fn query_version_middleware(req: Request, next: Next) -> Result<Response> {
    // Extract version from query parameters
    let version = req.uri().query()
        .and_then(|q| {
            q.split('&')
             .find(|param| param.starts_with("version="))
             .map(|param| param.split('=').nth(1)?.parse::<u32>().ok())
        })
        .flatten()
        .unwrap_or(1);
    
    // Add version to request extensions
    let mut req = req;
    req.extensions_mut().insert(ApiVersion(version));
    
    next.run(req).await
}

Versioned Models and Serializers

Handle different versions of data models:

use oxidite::prelude::*;
use serde::{Deserialize, Serialize};

// V1 User Model
#[derive(Serialize, Deserialize)]
pub struct UserV1 {
    pub id: u32,
    pub name: String,
    pub email: String,
}

// V2 User Model - with additional fields
#[derive(Serialize, Deserialize)]
pub struct UserV2 {
    pub id: u32,
    pub name: String,
    pub email: String,
    pub profile: UserProfile,
}

#[derive(Serialize, Deserialize)]
pub struct UserProfile {
    pub bio: Option<String>,
    pub avatar_url: Option<String>,
    pub preferences: UserPreferences,
}

#[derive(Serialize, Deserialize)]
pub struct UserPreferences {
    pub theme: String,
    pub notifications: bool,
}

// Version-aware handler
async fn get_user_versioned(
    Path(user_id): Path<u32>,
    req: Request
) -> Result<Response> {
    // Fetch user from database (simplified)
    let user = fetch_user_from_db(user_id).await?;
    
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        match version {
            1 => {
                let v1_user = UserV1 {
                    id: user.id,
                    name: user.name,
                    email: user.email,
                };
                Ok(Response::json(v1_user))
            }
            2 => {
                let v2_user = UserV2 {
                    id: user.id,
                    name: user.name,
                    email: user.email,
                    profile: UserProfile {
                        bio: Some("Default bio".to_string()),
                        avatar_url: Some(format!("https://example.com/avatar/{}.jpg", user.id)),
                        preferences: UserPreferences {
                            theme: "light".to_string(),
                            notifications: true,
                        },
                    },
                };
                Ok(Response::json(v2_user))
            }
            _ => Err(Error::NotImplemented),
        }
    } else {
        // Default to v1
        let v1_user = UserV1 {
            id: user.id,
            name: user.name,
            email: user.email,
        };
        Ok(Response::json(v1_user))
    }
}

// Simulated database fetch
async fn fetch_user_from_db(id: u32) -> Result<UserV2> {
    Ok(UserV2 {
        id,
        name: format!("User {}", id),
        email: format!("user{}@example.com", id),
        profile: UserProfile {
            bio: Some("Sample bio".to_string()),
            avatar_url: Some(format!("https://example.com/avatar/{}.jpg", id)),
            preferences: UserPreferences {
                theme: "light".to_string(),
                notifications: true,
            },
        },
    })
}

// Convert between versions
impl UserV2 {
    pub fn to_v1(self) -> UserV1 {
        UserV1 {
            id: self.id,
            name: self.name,
            email: self.email,
        }
    }
}

impl UserV1 {
    pub fn to_v2(self) -> UserV2 {
        UserV2 {
            id: self.id,
            name: self.name,
            email: self.email,
            profile: UserProfile {
                bio: None,
                avatar_url: None,
                preferences: UserPreferences {
                    theme: "light".to_string(),
                    notifications: false,
                },
            },
        }
    }
}

Version Negotiation

Implement automatic version negotiation:

use oxidite::prelude::*;

#[derive(Clone)]
struct ApiVersionManager {
    supported_versions: std::collections::HashSet<u32>,
    default_version: u32,
}

impl ApiVersionManager {
    fn new() -> Self {
        let mut supported = std::collections::HashSet::new();
        supported.insert(1);
        supported.insert(2);
        supported.insert(3);
        
        Self {
            supported_versions: supported,
            default_version: 1,
        }
    }
    
    fn negotiate_version(&self, req: &Request) -> u32 {
        // Try header version first
        if let Some(version) = self.extract_header_version(req) {
            if self.supported_versions.contains(&version) {
                return version;
            }
        }
        
        // Try query parameter
        if let Some(version) = self.extract_query_version(req) {
            if self.supported_versions.contains(&version) {
                return version;
            }
        }
        
        // Fall back to default
        self.default_version
    }
    
    fn extract_header_version(&self, req: &Request) -> Option<u32> {
        req.headers()
            .get("accept")
            .and_then(|hv| hv.to_str().ok())
            .and_then(|accept| {
                if accept.contains("application/vnd.myapi.v") {
                    accept.find("v").and_then(|pos| {
                        accept[pos + 1..].chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse::<u32>().ok()
                    })
                } else {
                    None
                }
            })
            .or_else(|| {
                req.headers()
                    .get("x-api-version")
                    .and_then(|hv| hv.to_str().ok())
                    .and_then(|version_str| version_str.parse::<u32>().ok())
            })
    }
    
    fn extract_query_version(&self, req: &Request) -> Option<u32> {
        req.uri().query()
            .and_then(|q| {
                q.split('&')
                 .find(|param| param.starts_with("version="))
                 .map(|param| param.split('=').nth(1)?.parse::<u32>().ok())
            })
            .flatten()
    }
}

// Version negotiation middleware
async fn version_negotiation_middleware(
    req: Request,
    next: Next,
    State(version_manager): State<Arc<ApiVersionManager>>
) -> Result<Response> {
    let negotiated_version = version_manager.negotiate_version(&req);
    
    let mut req = req;
    req.extensions_mut().insert(ApiVersion(negotiated_version));
    
    // Add version to response headers
    let mut response = next.run(req).await?;
    response.headers_mut().insert(
        "X-API-Version",
        format!("{}", negotiated_version).parse().unwrap()
    );
    
    Ok(response)
}

// Get version info endpoint
async fn api_version_info(State(version_manager): State<Arc<ApiVersionManager>>) -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "current_version": version_manager.default_version,
        "supported_versions": version_manager.supported_versions.iter().collect::<Vec<_>>(),
        "latest_stable": 2,
        "deprecation_warning": null
    })))
}

Deprecation and Sunset Policies

Manage deprecated versions:

use oxidite::prelude::*;

#[derive(Clone)]
struct VersionDeprecationPolicy {
    deprecated_versions: std::collections::HashMap<u32, DeprecationInfo>,
}

#[derive(Clone)]
struct DeprecationInfo {
    deprecation_date: chrono::DateTime<chrono::Utc>,
    sunset_date: chrono::DateTime<chrono::Utc>,
    migration_guide_url: String,
    alternative_endpoints: Vec<String>,
}

impl VersionDeprecationPolicy {
    fn new() -> Self {
        let mut deprecated = std::collections::HashMap::new();
        
        // Example: deprecate v1 on 2024-01-01, sunset on 2024-07-01
        deprecated.insert(1, DeprecationInfo {
            deprecation_date: chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z").unwrap().into(),
            sunset_date: chrono::DateTime::parse_from_rfc3339("2024-07-01T00:00:00Z").unwrap().into(),
            migration_guide_url: "https://docs.example.com/v1-to-v2-migration".to_string(),
            alternative_endpoints: vec!["/api/v2/users".to_string()],
        });
        
        Self {
            deprecated_versions: deprecated,
        }
    }
    
    fn check_deprecation(&self, version: u32) -> Option<&DeprecationInfo> {
        self.deprecated_versions.get(&version)
    }
    
    fn is_sunset(&self, version: u32) -> bool {
        if let Some(info) = self.check_deprecation(version) {
            chrono::Utc::now() > info.sunset_date
        } else {
            false
        }
    }
}

// Deprecation middleware
async fn deprecation_middleware(
    req: Request,
    next: Next,
    State(policy): State<Arc<VersionDeprecationPolicy>>
) -> Result<Response> {
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        if policy.is_sunset(*version) {
            return Err(Error::Gone("This API version has been sunset. Please upgrade to a newer version.".to_string()));
        }
        
        if let Some(deprecation_info) = policy.check_deprecation(*version) {
            let mut response = next.run(req).await?;
            
            // Add deprecation headers
            response.headers_mut().insert(
                "X-API-Deprecated",
                "true".parse().unwrap()
            );
            
            response.headers_mut().insert(
                "X-API-Deprecation-Date",
                deprecation_info.deprecation_date.to_rfc3339().parse().unwrap()
            );
            
            response.headers_mut().insert(
                "X-API-Sunset-Date",
                deprecation_info.sunset_date.to_rfc3339().parse().unwrap()
            );
            
            response.headers_mut().insert(
                "X-API-Migration-Guide",
                deprecation_info.migration_guide_url.parse().unwrap()
            );
            
            return Ok(response);
        }
    }
    
    next.run(req).await
}

Content Negotiation

Handle different content types based on version:

use oxidite::prelude::*;

// Content negotiation middleware
async fn content_negotiation_middleware(req: Request, next: Next) -> Result<Response> {
    // Determine response format based on Accept header and version
    let accept_header = req.headers()
        .get("accept")
        .and_then(|hv| hv.to_str().ok())
        .unwrap_or("*/*");
    
    let mut response = next.run(req).await?;
    
    // Set content type based on requested format
    if accept_header.contains("application/json") {
        response.headers_mut().insert(
            "Content-Type",
            "application/json".parse().unwrap()
        );
    } else if accept_header.contains("text/html") {
        response.headers_mut().insert(
            "Content-Type",
            "text/html".parse().unwrap()
        );
    } else {
        response.headers_mut().insert(
            "Content-Type",
            "application/json".parse().unwrap()
        );
    }
    
    Ok(response)
}

// Version-specific content types
async fn versioned_content_handler(req: Request) -> Result<Response> {
    let accept_header = req.headers()
        .get("accept")
        .and_then(|hv| hv.to_str().ok())
        .unwrap_or("*/*");
    
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        match (version, accept_header) {
            (1, accept) if accept.contains("application/vnd.myapi.v1+json") => {
                // V1 JSON response
                Ok(Response::json(serde_json::json!({
                    "users": [
                        {"id": 1, "name": "John"}
                    ]
                })))
            }
            (2, accept) if accept.contains("application/vnd.myapi.v2+json") => {
                // V2 JSON response with more fields
                Ok(Response::json(serde_json::json!({
                    "data": {
                        "users": [
                            {
                                "id": 1,
                                "name": "John",
                                "meta": {
                                    "total": 1
                                }
                            }
                        ]
                    },
                    "links": {
                        "self": "/api/v2/users",
                        "next": "/api/v2/users?page=2"
                    }
                })))
            }
            _ => {
                // Fallback response
                Ok(Response::json(serde_json::json!({
                    "error": "Unsupported version or content type"
                })))
            }
        }
    } else {
        // Default response
        Ok(Response::json(serde_json::json!({
            "users": [
                {"id": 1, "name": "John"}
            ]
        })))
    }
}

Version-Specific Middleware

Apply different middleware based on API version:

use oxidite::prelude::*;

// Version-specific rate limiting
async fn v1_rate_limit_middleware(req: Request, next: Next) -> Result<Response> {
    // V1 has stricter limits
    let max_requests = 100; // per hour for v1
    check_rate_limit(&req, max_requests, "v1")?;
    next.run(req).await
}

async fn v2_rate_limit_middleware(req: Request, next: Next) -> Result<Response> {
    // V2 has higher limits
    let max_requests = 1000; // per hour for v2
    check_rate_limit(&req, max_requests, "v2")?;
    next.run(req).await
}

fn check_rate_limit(_req: &Request, _limit: usize, _version: &str) -> Result<()> {
    // Implementation would check rate limits
    Ok(())
}

// Version-aware router
async fn versioned_router(req: Request) -> Result<Response> {
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        match version {
            1 => {
                // Apply v1-specific middleware and handlers
                v1_rate_limit_middleware(req, Next::new(|req| async {
                    // V1 handler
                    Ok(Response::json(serde_json::json!({"version": "v1"})))
                })).await
            }
            2 => {
                // Apply v2-specific middleware and handlers
                v2_rate_limit_middleware(req, Next::new(|req| async {
                    // V2 handler
                    Ok(Response::json(serde_json::json!({"version": "v2"})))
                })).await
            }
            _ => Err(Error::NotImplemented),
        }
    } else {
        // Default to v1
        v1_rate_limit_middleware(req, Next::new(|req| async {
            Ok(Response::json(serde_json::json!({"version": "v1", "default": true})))
        })).await
    }
}

// Next type for middleware chaining
struct Next<F> {
    handler: F,
}

impl<F> Next<F> {
    fn new(handler: F) -> Self {
        Self { handler }
    }
    
    async fn run(self, req: Request) -> Result<Response> {
        (self.handler)(req).await
    }
}

Testing Versioned APIs

Write tests for versioned APIs:

use oxidite::prelude::*;
use oxidite_testing::{TestServer, RequestBuilder};

#[cfg(test)]
mod version_tests {
    use super::*;
    
    #[tokio::test]
    async fn test_v1_api() {
        let server = TestServer::new(|router| {
            router.get("/api/v1/users", v1_get_users);
        }).await;
        
        let response = server
            .get("/api/v1/users")
            .header("Accept", "application/json")
            .send()
            .await;
        
        assert_eq!(response.status(), 200);
        
        let json: serde_json::Value = response.json().await;
        assert!(json.as_array().unwrap().first().unwrap()["email"].is_string());
    }
    
    #[tokio::test]
    async fn test_v2_api() {
        let server = TestServer::new(|router| {
            router.get("/api/v2/users", v2_get_users);
        }).await;
        
        let response = server
            .get("/api/v2/users")
            .header("Accept", "application/json")
            .send()
            .await;
        
        assert_eq!(response.status(), 200);
        
        let json: serde_json::Value = response.json().await;
        assert!(json.as_array().unwrap().first().unwrap()["profile"].isObject());
    }
    
    #[tokio::test]
    async fn test_header_versioning() {
        let server = TestServer::new(|router| {
            router.get("/users")
                .middleware(version_middleware)
                .handler(get_users_by_version);
        }).await;
        
        let response = server
            .get("/users")
            .header("Accept", "application/vnd.myapi.v2+json")
            .send()
            .await;
        
        assert_eq!(response.status(), 200);
        
        let json: serde_json::Value = response.json().await;
        assert!(json.as_array().unwrap().first().unwrap()["profile"].isObject());
    }
    
    #[tokio::test]
    async fn test_deprecated_version() {
        let policy = Arc::new(VersionDeprecationPolicy::new());
        let server = TestServer::new(move |router| {
            let policy_clone = policy.clone();
            router.get("/users")
                .with_state(policy_clone)
                .middleware(deprecation_middleware)
                .handler(|_| async { Ok(Response::json(serde_json::json!({"test": true}))) });
        }).await;
        
        let response = server
            .get("/users")
            .header("X-API-Version", "1")
            .send()
            .await;
        
        assert_eq!(response.status(), 200);
        assert!(response.headers().get("X-API-Deprecated").is_some());
    }
}

Migration Strategies

Plan for API migrations:

use oxidite::prelude::*;

// Migration guide endpoint
async fn migration_guide(_req: Request) -> Result<Response> {
    Ok(Response::json(serde_json::json!({
        "v1_to_v2": {
            "breaking_changes": [
                "User objects now include profile information",
                "Response format changed to include metadata",
                "New pagination structure"
            ],
            "migration_steps": [
                "Update client libraries",
                "Modify data processing logic",
                "Update error handling",
                "Test with v2 endpoints"
            ],
            "timeline": {
                "deprecation_date": "2024-01-01",
                "sunset_date": "2024-07-01",
                "recommended_action": "Migrate to v2 before deprecation date"
            }
        }
    })))
}

// Feature flags for gradual rollout
#[derive(Clone)]
struct FeatureFlags {
    enabled_features: std::collections::HashSet<String>,
}

impl FeatureFlags {
    fn new() -> Self {
        let mut features = std::collections::HashSet::new();
        features.insert("new_user_format".to_string());
        features.insert("enhanced_pagination".to_string());
        
        Self {
            enabled_features: features,
        }
    }
    
    fn is_enabled(&self, feature: &str) -> bool {
        self.enabled_features.contains(feature)
    }
}

// Version with feature flags
async fn feature_flagged_handler(
    req: Request,
    State(flags): State<Arc<FeatureFlags>>
) -> Result<Response> {
    if let Some(ApiVersion(version)) = req.extensions().get::<ApiVersion>() {
        match version {
            2 => {
                let mut response_data = serde_json::json!({
                    "users": [
                        {
                            "id": 1,
                            "name": "John"
                        }
                    ]
                });
                
                // Conditionally add features based on flags
                if flags.is_enabled("enhanced_pagination") {
                    response_data["pagination"] = serde_json::json!({
                        "page": 1,
                        "per_page": 10,
                        "total": 100,
                        "pages": 10
                    });
                }
                
                if flags.is_enabled("new_user_format") {
                    if let Some(users) = response_data["users"].as_array_mut() {
                        for user in users {
                            user["profile"] = serde_json::json!({
                                "bio": "Software developer",
                                "avatar_url": "https://example.com/avatar.jpg"
                            });
                        }
                    }
                }
                
                Ok(Response::json(response_data))
            }
            _ => v1_response(), // Default to v1 behavior
        }
    } else {
        v1_response()
    }
}

Summary

API versioning in Oxidite supports multiple strategies:

  • URL-based: /api/v1/resource (most common)
  • Header-based: Accept: application/vnd.api.v1+json
  • Query parameter: ?version=1
  • Media type versioning: Custom content types

Best practices include:

  • Clear deprecation policies with advance notice
  • Automated version negotiation
  • Proper error handling for unsupported versions
  • Comprehensive testing across versions
  • Gradual migration strategies
  • Feature flags for controlled rollouts

Choose the versioning strategy that best fits your API consumers’ needs and your team’s capabilities.